feat(server): the whole server half (phase 3, slice 1) #3
15
module.json
15
module.json
@@ -1,8 +1,17 @@
|
|||||||
{
|
{
|
||||||
"id": "uo",
|
"id": "uo",
|
||||||
"name": "Ultima Online",
|
"name": "Ultima Online",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"coreApi": "^1.0.0",
|
"coreApi": "^1.1.0",
|
||||||
"server": "server/index.js",
|
"server": "server/index.js",
|
||||||
"client": { "entry": "client/dist/entry.js" }
|
"client": { "entry": "client/dist/entry.js" },
|
||||||
|
"schema": "server/db/schema.sql",
|
||||||
|
"purge": "server/db/purge.sql",
|
||||||
|
"mounts": {
|
||||||
|
"public": ["/shard", "/atlas"],
|
||||||
|
"admin": ["/shard", "/uo-link"],
|
||||||
|
"player": ["/shard"]
|
||||||
|
},
|
||||||
|
"extensions": ["admin.users.detail"],
|
||||||
|
"capabilities": ["shard", "atlas", "market", "governors", "guilds", "houses", "champs", "cliloc"]
|
||||||
}
|
}
|
||||||
|
|||||||
121
server/boot.js
Normal file
121
server/boot.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ── onBoot / onShutdown ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The eight UO call sites that used to sit in core's `server.js`. `register()`
|
||||||
|
// runs with no database (MODULE_API.md §2.2); everything here runs with one.
|
||||||
|
//
|
||||||
|
// Core dispatches `onBoot` after `ensureSchema` and the schema-fragment replay,
|
||||||
|
// and **before the HTTP listener binds** — so the tables these functions touch
|
||||||
|
// exist, and nothing is served until the warm-up finishes. That ordering is the
|
||||||
|
// contract's promise rather than an accident, and it is why `onBoot` has no
|
||||||
|
// timeout: a module that must not serve traffic until a cache is warm only gets
|
||||||
|
// that guarantee if the listener is still closed.
|
||||||
|
//
|
||||||
|
// **One behavioural change, and it is deliberate.** In core, `uoLinkSocket.start()`
|
||||||
|
// and the sidecar health probe ran AFTER the listener bound; here they run before
|
||||||
|
// it. `start()` returns as soon as the reconnecting client is armed, so that part
|
||||||
|
// is free — but the probe is a real HTTP call to the sidecar, and an unreachable
|
||||||
|
// sidecar must not hold the site closed. It is therefore fired and NOT awaited,
|
||||||
|
// with its own catch. Reporting whether the bridge is up is diagnostics; being up
|
||||||
|
// is not a precondition for serving a page, and the site is required to degrade
|
||||||
|
// gracefully when the shard is down.
|
||||||
|
//
|
||||||
|
// Everything here is best-effort by the same rule. A module whose `onBoot`
|
||||||
|
// throws is marked `startup_failed` and its routes answer 503 (§4.4), which is
|
||||||
|
// the right outcome for a broken module — but "the operator has not configured a
|
||||||
|
// ServUO path" is not a broken module, and neither is "the shard is offline".
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
|
const uoLinkSocket = require('./utils/uoLinkSocket')
|
||||||
|
const uoLinkClient = require('./utils/uoLinkClient')
|
||||||
|
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const shardBroadcast = require('./utils/shardBroadcast')
|
||||||
|
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||||
|
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
|
||||||
|
const shardMarket = require('./model/shardMarket/shardMarket.model')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort startup probe of the uo-link sidecar.
|
||||||
|
*
|
||||||
|
* Logs whether it is reachable and warns loudly on a protocol mismatch —
|
||||||
|
* fail-fast visibility rather than silently mis-parsing a newer wire format.
|
||||||
|
* Never throws, and is never awaited by `onBoot`.
|
||||||
|
*/
|
||||||
|
async function checkUoLink() {
|
||||||
|
const log = core.logger('boot')
|
||||||
|
const config = await uoLinkConfig.getSafe()
|
||||||
|
if (!config.enabled) return
|
||||||
|
const health = await uoLinkClient.health()
|
||||||
|
if (!health.ok) {
|
||||||
|
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
error: health.error || `status ${health.status}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
|
||||||
|
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
|
||||||
|
pinned: config.protocol,
|
||||||
|
sidecar: health.data.protocol,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.info('uo-link sidecar reachable', {
|
||||||
|
pluginConnected: health.data && health.data.plugin_connected,
|
||||||
|
protocol: health.data && health.data.protocol,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBoot() {
|
||||||
|
const log = core.logger('boot')
|
||||||
|
|
||||||
|
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
|
||||||
|
// change over its lifetime — facets get added, replaced or renamed — so the
|
||||||
|
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
|
||||||
|
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
|
||||||
|
// no database write.
|
||||||
|
//
|
||||||
|
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||||
|
// malformed file must never stop the site coming up. A refresh that would
|
||||||
|
// REMOVE a facet is staged for admin approval instead of being applied.
|
||||||
|
await shardAtlas.refreshOnBoot()
|
||||||
|
|
||||||
|
// Refresh the cliloc table (UO's id → display-string map) from the file the
|
||||||
|
// operator converted out of their own client. Same contract as the atlas:
|
||||||
|
// hash-gated so an unchanged file costs one read, and best-effort so a missing
|
||||||
|
// or wrong-format file never stops the site coming up — it just means item
|
||||||
|
// names render as ids, which is what they did before the table existed.
|
||||||
|
const clilocResult = await shardClilocs.refreshOnBoot()
|
||||||
|
|
||||||
|
// A cliloc import changes what item names RESOLVE to, and the marketplace
|
||||||
|
// stores those names denormalized (shard_vendor_items.display_name) so it can
|
||||||
|
// index and search them. The shard's market sweep will not re-send an unchanged
|
||||||
|
// shop just because the site learned what its items are called, so the backfill
|
||||||
|
// has to be pulled rather than waited for. Only after an actual import — the
|
||||||
|
// common boot is hash-gated to a no-op and must stay one.
|
||||||
|
if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames()
|
||||||
|
|
||||||
|
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
||||||
|
// connects when the admin has enabled the integration and saved a token, so
|
||||||
|
// this is a no-op on shards that haven't configured the sidecar.
|
||||||
|
try {
|
||||||
|
await uoLinkSocket.start()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately not awaited — see the header. An unreachable sidecar would
|
||||||
|
// otherwise hold the listener closed for the length of an HTTP timeout.
|
||||||
|
checkUoLink().catch((err) => log.warn('uo-link startup probe failed', { error: err.message }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onShutdown() {
|
||||||
|
// Core runs this FIRST in its signal handler, while everything it handed over
|
||||||
|
// still works — the pool is open, the push dispatcher is up, the SSE fan-out
|
||||||
|
// is live. It is the only chance to close cleanly, and it is budgeted, so a
|
||||||
|
// hook that will not let go costs five seconds rather than the whole shutdown.
|
||||||
|
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||||
|
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { onBoot, onShutdown, checkUoLink }
|
||||||
172
server/config/shardStreams.js
Normal file
172
server/config/shardStreams.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||||
|
//
|
||||||
|
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||||
|
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||||
|
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||||
|
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||||
|
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||||
|
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||||
|
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||||
|
//
|
||||||
|
// Two families:
|
||||||
|
// • public / opt-in — no linked game account required; delivered to every
|
||||||
|
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||||
|
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||||
|
// can never produce a public push.
|
||||||
|
// • personal / owner-keyed — require a linked game account; delivered ONLY to
|
||||||
|
// the owning user's devices (resolved from the event's
|
||||||
|
// game account via shardLinks), never fanned out publicly.
|
||||||
|
//
|
||||||
|
// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref });
|
||||||
|
// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the
|
||||||
|
// real, ownership-checked content over the authenticated API. So even a leaked
|
||||||
|
// ntfy topic reveals nothing (docs/android/PLAN.md §11).
|
||||||
|
|
||||||
|
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||||
|
|
||||||
|
const STREAMS = [
|
||||||
|
{
|
||||||
|
id: 'server.status',
|
||||||
|
label: 'Server up / down',
|
||||||
|
description: 'The shard comes online or goes offline.',
|
||||||
|
personal: false,
|
||||||
|
requiresLinkedAccount: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'idoc.warning',
|
||||||
|
label: 'IDOC warnings',
|
||||||
|
description: 'A house falls into its final (IDOC) decay stage.',
|
||||||
|
personal: false,
|
||||||
|
requiresLinkedAccount: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'champ.start',
|
||||||
|
label: 'Champion spawn starts',
|
||||||
|
description: 'A champion spawn becomes active.',
|
||||||
|
personal: false,
|
||||||
|
requiresLinkedAccount: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'governor.election',
|
||||||
|
label: 'Governor elections',
|
||||||
|
description: 'A town elects a new governor.',
|
||||||
|
personal: false,
|
||||||
|
requiresLinkedAccount: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vendor.sale',
|
||||||
|
label: 'Your vendor sold an item',
|
||||||
|
description: 'One of your player vendors made a sale.',
|
||||||
|
personal: true,
|
||||||
|
requiresLinkedAccount: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'house.idoc',
|
||||||
|
label: 'Your house entered IDOC',
|
||||||
|
description: 'One of your houses fell into its final decay stage.',
|
||||||
|
personal: true,
|
||||||
|
requiresLinkedAccount: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'account.login',
|
||||||
|
label: 'A login to your account',
|
||||||
|
description: 'An authentication attempt against your game account.',
|
||||||
|
personal: true,
|
||||||
|
requiresLinkedAccount: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||||
|
// Derived from this file's own catalog rather than read back out of the registry:
|
||||||
|
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||||
|
// by registering something that happens to share an id.
|
||||||
|
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||||
|
|
||||||
|
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||||
|
// are upserts, not discrete "started"/"elected" events — see docs/link
|
||||||
|
// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests
|
||||||
|
// pass a fresh tracker; a module-level default backs the live dispatcher.
|
||||||
|
function createTracker() {
|
||||||
|
return { champActive: new Map(), cityGovernor: new Map() }
|
||||||
|
}
|
||||||
|
const defaultTracker = createTracker()
|
||||||
|
|
||||||
|
// Per-kind mappers, each pushing 0+ targets onto `out` (and updating `tracker`
|
||||||
|
// for the upsert-transition kinds). Split out of mapShardEvent so that function
|
||||||
|
// stays a trivial dispatch + the public-safety filter.
|
||||||
|
const serverStatusUp = (event, tracker, out) =>
|
||||||
|
out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
|
||||||
|
const serverStatusDown = (event, tracker, out) => out.push({ streamId: 'server.status', ref: 'down' })
|
||||||
|
|
||||||
|
const EVENT_MAPPERS = {
|
||||||
|
'server.hello': serverStatusUp,
|
||||||
|
'server.shutdown': serverStatusDown,
|
||||||
|
'server.crashed': serverStatusDown,
|
||||||
|
'house.decay': (event, tracker, out) => {
|
||||||
|
if (String(event.to).toUpperCase() !== 'IDOC') return
|
||||||
|
const ref = String(event.serial ?? '')
|
||||||
|
out.push({ streamId: 'idoc.warning', ref }) // public — location only
|
||||||
|
if (event.ownerAcct) {
|
||||||
|
out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'champ.update': (event, tracker, out) => {
|
||||||
|
const { serial } = event
|
||||||
|
if (serial == null) return
|
||||||
|
const wasActive = tracker.champActive.get(serial) === true
|
||||||
|
const isActive = event.active === true
|
||||||
|
tracker.champActive.set(serial, isActive)
|
||||||
|
if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
|
||||||
|
},
|
||||||
|
'champ.remove': (event, tracker) => {
|
||||||
|
if (event.serial != null) tracker.champActive.delete(event.serial)
|
||||||
|
},
|
||||||
|
'city.update': (event, tracker, out) => {
|
||||||
|
const { city } = event
|
||||||
|
if (!city) return
|
||||||
|
const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
|
||||||
|
const prev = tracker.cityGovernor.get(city)
|
||||||
|
tracker.cityGovernor.set(city, gov)
|
||||||
|
// Only a real transition to a new governor, and never on first sight
|
||||||
|
// (prev === undefined) so a reconnect snapshot isn't read as an election.
|
||||||
|
if (prev !== undefined && gov && gov !== prev) {
|
||||||
|
out.push({ streamId: 'governor.election', ref: String(city) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'vendor.sale': (event, tracker, out) => {
|
||||||
|
if (event.ownerAcct) {
|
||||||
|
out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'account.login.attempt': (event, tracker, out) => {
|
||||||
|
if (event.acct) {
|
||||||
|
out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }).
|
||||||
|
// May yield 0, 1, or 2 targets (an owner house.decay produces both the public
|
||||||
|
// idoc.warning and the personal house.idoc). Pure given `tracker`.
|
||||||
|
function mapShardEvent(event, tracker = defaultTracker) {
|
||||||
|
if (!event || typeof event.kind !== 'string') return []
|
||||||
|
const kind = event.kind
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
const mapper = EVENT_MAPPERS[kind]
|
||||||
|
if (mapper) mapper(event, tracker, out)
|
||||||
|
|
||||||
|
// Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe
|
||||||
|
// kind. Personal targets are owner-keyed and delivered solely to the owner, so
|
||||||
|
// they are exempt from the public allowlist (that is the whole point of the
|
||||||
|
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||||
|
// even if a future mapping case is added carelessly.
|
||||||
|
//
|
||||||
|
// This filter, the kinds it reads and the streams it protects now all live in
|
||||||
|
// one file and move together — the reason PR 4 dropped the contract's
|
||||||
|
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||||
|
// module (MODULE_API.md §2.4).
|
||||||
|
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||||
112
server/core.js
Normal file
112
server/core.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// ── Everything this module reaches in core ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
|
||||||
|
// code below it — models, utils, controllers — is ordinary Node that requires
|
||||||
|
// its dependencies at file scope, the way it did when it lived in core. This
|
||||||
|
// file is what lets both be true.
|
||||||
|
//
|
||||||
|
// **The shape is a lazy accessor, not a stored reference, and that is the whole
|
||||||
|
// point.** A ported file writes
|
||||||
|
//
|
||||||
|
// const { query } = require('../../core')
|
||||||
|
//
|
||||||
|
// at require time, which is before `register()` has been called and therefore
|
||||||
|
// before any `ctx` exists. Handing out `ctx.db.query` there would hand out
|
||||||
|
// `undefined`, permanently, and the failure would surface much later as a
|
||||||
|
// TypeError inside a model. So every export here is a stable function that
|
||||||
|
// resolves `ctx` when it is CALLED. Require order stops mattering, and the port
|
||||||
|
// stays a one-line import change per file rather than a signature change per
|
||||||
|
// function.
|
||||||
|
//
|
||||||
|
// The other half of the same rule: nothing here may be destructured off `ctx`
|
||||||
|
// at init time either, for the same reason in the other direction — core is
|
||||||
|
// free to hand over a getter (`ctx.site.baseUrl` is one), and a value captured
|
||||||
|
// once is a value that cannot change.
|
||||||
|
//
|
||||||
|
// If `ctx` is missing, every accessor throws with the same message. That is
|
||||||
|
// deliberate: the only way to reach one before `register()` is a require cycle
|
||||||
|
// or a test that forgot to call `init`, and both want naming, not `undefined`.
|
||||||
|
|
||||||
|
let ctx = null
|
||||||
|
|
||||||
|
function need() {
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('module-uo: core accessed before register() — see server/core.js')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once, first thing in `register()`. */
|
||||||
|
function init(value) {
|
||||||
|
ctx = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. Nothing in the module calls this; there is no de-registration. */
|
||||||
|
function _reset() {
|
||||||
|
ctx = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// A logger that can be taken at require time and used after `register()`.
|
||||||
|
//
|
||||||
|
// Ported files write `const log = require('../core').logger('shard-ingest')` at
|
||||||
|
// file scope — the same shape as core's `require('./logger')('…')` — so the
|
||||||
|
// object returned has to exist before `ctx` does. It is a façade whose four
|
||||||
|
// methods each resolve the real logger on call. Core namespaces it with the
|
||||||
|
// module id, so these come out as `[uo:shard-ingest]`.
|
||||||
|
function logger(namespace) {
|
||||||
|
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
|
||||||
|
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
_reset,
|
||||||
|
logger,
|
||||||
|
|
||||||
|
// Shared server dependencies. Core owns exactly one express, as it owns
|
||||||
|
// exactly one React on the client, and for the same reason: a second copy in
|
||||||
|
// the process is a second Router prototype and a second set of instanceof
|
||||||
|
// checks. A module lives outside core's `server/`, so it could not resolve
|
||||||
|
// these for itself even if it were allowed to (§7.2).
|
||||||
|
get express() { return need().express },
|
||||||
|
get validator() { return need().validator },
|
||||||
|
|
||||||
|
// Database. `query` is the one every `*.db.js` uses; `pool` is for the
|
||||||
|
// streamed atlas import, which needs a connection it can hold.
|
||||||
|
query: (...args) => need().db.query(...args),
|
||||||
|
get pool() { return need().db.pool },
|
||||||
|
|
||||||
|
// Core state a module may read or append to, each narrowed to what is
|
||||||
|
// actually used (§2.3).
|
||||||
|
settings: {
|
||||||
|
get: (...args) => need().settings.get(...args),
|
||||||
|
set: (...args) => need().settings.set(...args),
|
||||||
|
getInstanceName: (...args) => need().settings.getInstanceName(...args),
|
||||||
|
},
|
||||||
|
activity: { log: (...args) => need().activity.log(...args) },
|
||||||
|
users: { getById: (...args) => need().users.getById(...args) },
|
||||||
|
posts: {
|
||||||
|
listAll: (...args) => need().posts.listAll(...args),
|
||||||
|
getById: (...args) => need().posts.getById(...args),
|
||||||
|
linkAnnounceJob: (...args) => need().posts.linkAnnounceJob(...args),
|
||||||
|
markAnnounced: (...args) => need().posts.markAnnounced(...args),
|
||||||
|
},
|
||||||
|
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||||
|
push: { publish: (...args) => need().push.publish(...args) },
|
||||||
|
secretBox: {
|
||||||
|
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||||
|
decrypt: (...args) => need().secretBox.decrypt(...args),
|
||||||
|
},
|
||||||
|
get uploads() { return need().uploads },
|
||||||
|
|
||||||
|
// Middleware. Taken as values rather than wrapped, because express stores the
|
||||||
|
// function reference at mount time — a wrapper would be what ends up in the
|
||||||
|
// stack, and `requireRole('admin')` returns a middleware rather than being
|
||||||
|
// one. Routers are built inside `register()`, so `ctx` is set by then.
|
||||||
|
get middleware() { return need().middleware },
|
||||||
|
|
||||||
|
// Deployment facts.
|
||||||
|
get baseUrl() { return need().site.baseUrl },
|
||||||
|
get moduleRoot() { return need().paths.moduleRoot },
|
||||||
|
get moduleId() { return need().moduleId },
|
||||||
|
}
|
||||||
24
server/data/spawnAtlas.art.example.json
Normal file
24
server/data/spawnAtlas.art.example.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
|
||||||
|
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
|
||||||
|
"`npm run atlas:import` — the art map is read on every atlas refresh.",
|
||||||
|
"",
|
||||||
|
"This project ships NO creature artwork and never will. UO sprites live in your",
|
||||||
|
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
|
||||||
|
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
|
||||||
|
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
|
||||||
|
"map each creature slug to its file name here.",
|
||||||
|
"",
|
||||||
|
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
|
||||||
|
"nor the images can be committed by accident.",
|
||||||
|
"",
|
||||||
|
"Keys are creature slugs, as reported by the atlas API and derived from the type",
|
||||||
|
"names in your own shard's Spawns/*.xml. Values are file names relative to",
|
||||||
|
"server/uploads/atlas/. Any creature with no entry here simply renders without",
|
||||||
|
"art — that is the default and fully supported state, not a degraded one."
|
||||||
|
],
|
||||||
|
"lizardman": "lizardman.png",
|
||||||
|
"orc": "orc.png",
|
||||||
|
"dragon": "dragon.png"
|
||||||
|
}
|
||||||
52
server/db/purge.sql
Normal file
52
server/db/purge.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- ── module-uo's teardown ──────────────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
|
||||||
|
-- Nothing on the boot path ever executes this file — uninstalling a module
|
||||||
|
-- leaves its data alone, and removing the data is a separate decision an
|
||||||
|
-- operator has to make on purpose.
|
||||||
|
--
|
||||||
|
-- It exists because `schema.sql` does. A module that can create tables and
|
||||||
|
-- cannot drop them leaves an operator with orphaned data and no supported way
|
||||||
|
-- to remove it, which is why core refuses to load a module that declares one
|
||||||
|
-- without the other.
|
||||||
|
--
|
||||||
|
-- **The order is the reverse of creation, and that is load-bearing**: two of
|
||||||
|
-- these tables carry a foreign key into core's `users`, and several reference
|
||||||
|
-- each other. Dropping a parent before its children fails on the constraint,
|
||||||
|
-- and a purge that fails halfway is worse than one that does not run — it
|
||||||
|
-- leaves exactly the orphaned data this file exists to remove. `IF EXISTS` on
|
||||||
|
-- every line so a partially-installed module still tears down cleanly.
|
||||||
|
--
|
||||||
|
-- What is NOT here, deliberately: rows this module wrote into core's tables.
|
||||||
|
-- `notification_subs` rows for `shard.*` streams and `announce_job_legs` rows
|
||||||
|
-- with leg `towncrier` belong to core's tables, and a module does not delete
|
||||||
|
-- from those — core prunes them when it drops the registrations, which it can
|
||||||
|
-- do because it knows which registrant owned what.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS `shard_atlas_pending`;
|
||||||
|
DROP TABLE IF EXISTS `shard_atlas_meta`;
|
||||||
|
DROP TABLE IF EXISTS `shard_cliloc_meta`;
|
||||||
|
DROP TABLE IF EXISTS `shard_clilocs`;
|
||||||
|
DROP TABLE IF EXISTS `shard_champion_spawns`;
|
||||||
|
DROP TABLE IF EXISTS `shard_landmarks`;
|
||||||
|
DROP TABLE IF EXISTS `shard_regions`;
|
||||||
|
DROP TABLE IF EXISTS `shard_spawn_point_types`;
|
||||||
|
DROP TABLE IF EXISTS `shard_spawn_points`;
|
||||||
|
DROP TABLE IF EXISTS `shard_spawn_creatures`;
|
||||||
|
DROP TABLE IF EXISTS `shard_feature_visibility`;
|
||||||
|
DROP TABLE IF EXISTS `shard_vendor_items`;
|
||||||
|
DROP TABLE IF EXISTS `shard_vendors`;
|
||||||
|
DROP TABLE IF EXISTS `shard_points_boards`;
|
||||||
|
DROP TABLE IF EXISTS `shard_ruleset`;
|
||||||
|
DROP TABLE IF EXISTS `shard_presence`;
|
||||||
|
DROP TABLE IF EXISTS `shard_governor_terms`;
|
||||||
|
DROP TABLE IF EXISTS `shard_governors`;
|
||||||
|
DROP TABLE IF EXISTS `shard_guilds`;
|
||||||
|
DROP TABLE IF EXISTS `shard_pages`;
|
||||||
|
DROP TABLE IF EXISTS `shard_champs`;
|
||||||
|
DROP TABLE IF EXISTS `shard_account_links`;
|
||||||
|
DROP TABLE IF EXISTS `shard_houses`;
|
||||||
|
DROP TABLE IF EXISTS `shard_economy`;
|
||||||
|
DROP TABLE IF EXISTS `shard_online`;
|
||||||
|
DROP TABLE IF EXISTS `shard_events`;
|
||||||
|
DROP TABLE IF EXISTS `uo_link_config`;
|
||||||
617
server/db/schema.sql
Normal file
617
server/db/schema.sql
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
-- ── module-uo's schema fragment ───────────────────────────────────────────
|
||||||
|
--
|
||||||
|
-- Replayed by core on EVERY boot, after core's own schema.sql and before
|
||||||
|
-- seedDefaults (MODULE_API.md §2.6). Everything here is therefore idempotent:
|
||||||
|
-- every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
||||||
|
-- IF NOT EXISTS, because a statement that succeeds once and fails afterwards
|
||||||
|
-- presents as a module that worked until the first restart.
|
||||||
|
--
|
||||||
|
-- Core validates this file at LOAD time, before anything mounts — statement by
|
||||||
|
-- statement, split by the same code that splits core's schema. The rules it
|
||||||
|
-- enforces and the reason each exists:
|
||||||
|
--
|
||||||
|
-- • Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE. Not a
|
||||||
|
-- DROP denylist — this file replays every boot, so TRUNCATE or DELETE would
|
||||||
|
-- empty a table on each restart.
|
||||||
|
-- • Every table is prefixed. `shard_*` and `uo_link_*` are grandfathered to
|
||||||
|
-- this module by name (loader.js LEGACY_TABLE_PREFIXES): they predate the
|
||||||
|
-- module system by two years, they hold live data, and renaming them would
|
||||||
|
-- be a migration this workstream deliberately does not do. Every module
|
||||||
|
-- written after this one prefixes with its own id.
|
||||||
|
-- • No table core declares may appear here, and no table another module has
|
||||||
|
-- claimed.
|
||||||
|
--
|
||||||
|
-- Two tables carry a foreign key INTO core (`users`), which is allowed and is
|
||||||
|
-- why the replay order matters: core's schema is already in place when this
|
||||||
|
-- runs, so `users` exists. The reverse — a core table referencing one of these
|
||||||
|
-- — does not occur and must not: it would make core's schema depend on a module
|
||||||
|
-- being installed.
|
||||||
|
--
|
||||||
|
-- Teardown is `purge.sql`, which is never run by a boot. See it for the drop
|
||||||
|
-- order, which is the reverse of the dependency order here.
|
||||||
|
|
||||||
|
|
||||||
|
-- ── uo-link sidecar ────────────────────────────────────────────────────────
|
||||||
|
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
|
||||||
|
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
|
||||||
|
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
|
||||||
|
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
|
||||||
|
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
|
||||||
|
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
|
||||||
|
-- in production the sidecar runs on a different host from the website. `status`/
|
||||||
|
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
|
||||||
|
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
|
||||||
|
-- so a shard restart can be detected (and caches dropped).
|
||||||
|
CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
base_url VARCHAR(255) NULL,
|
||||||
|
ws_url VARCHAR(255) NULL,
|
||||||
|
auth_token_enc TEXT NULL,
|
||||||
|
protocol INT NOT NULL DEFAULT 3,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
|
status_detail VARCHAR(500) NULL,
|
||||||
|
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
last_event_at DATETIME NULL,
|
||||||
|
boot_id VARCHAR(64) NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Append-only log of notable shard events ingested from the uo-link WebSocket
|
||||||
|
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
|
||||||
|
-- client writes here, and the public/admin read endpoints + live feeds read from
|
||||||
|
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
|
||||||
|
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||||
|
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||||
|
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||||
|
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
|
||||||
|
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
|
||||||
|
-- WS-reconnect backfill never double-inserts.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_events (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
kind VARCHAR(48) NOT NULL,
|
||||||
|
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
|
||||||
|
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
|
||||||
|
payload JSON NOT NULL, -- the full event object
|
||||||
|
dedupe_key CHAR(40) NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_events_kind_t (kind, t),
|
||||||
|
INDEX idx_shard_events_t (t)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
|
||||||
|
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
|
||||||
|
-- server.hello.bootId). web_id is the linked website user id (present when the
|
||||||
|
-- account is linked), so the roster can be correlated to site accounts.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_online (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
|
||||||
|
name VARCHAR(120) NULL,
|
||||||
|
acct VARCHAR(120) NULL,
|
||||||
|
web_id INT NULL,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
hits INT NULL,
|
||||||
|
hits_max INT NULL,
|
||||||
|
mana INT NULL,
|
||||||
|
mana_max INT NULL,
|
||||||
|
stam INT NULL,
|
||||||
|
stam_max INT NULL,
|
||||||
|
str INT NULL,
|
||||||
|
dex INT NULL,
|
||||||
|
`int` INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_online_acct (acct)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
|
||||||
|
-- append-only so the public status page can render a supply-over-time sparkline.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_economy (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
accounts INT NULL, -- number of accounts included in the total
|
||||||
|
gold BIGINT NULL, -- total gold supply across all accounts
|
||||||
|
t BIGINT NOT NULL, -- sample time, epoch ms
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_economy_t (t)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
|
||||||
|
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
|
||||||
|
-- indexed lookup rather than a scan.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_houses (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||||
|
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
region VARCHAR(120) NULL,
|
||||||
|
name VARCHAR(160) NULL,
|
||||||
|
owner_serial VARCHAR(20) NULL,
|
||||||
|
owner_acct VARCHAR(120) NULL,
|
||||||
|
built_on DATETIME NULL,
|
||||||
|
last_refreshed DATETIME NULL,
|
||||||
|
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_houses_idoc (is_idoc)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
-- a single user may link several game accounts.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_account_links (
|
||||||
|
account VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
char_name VARCHAR(120) NULL,
|
||||||
|
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_shard_links_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Current champion-spawn board, upserted on champ.update and removed on
|
||||||
|
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
|
||||||
|
-- the public Champions page (and its live deltas) survive a shard outage, the
|
||||||
|
-- same way shard_online / shard_houses do. Three families share one table, told
|
||||||
|
-- apart by `category` (champion | mini | sea); category-specific fields (level,
|
||||||
|
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
|
||||||
|
-- not have to model every variant.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_champs (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
|
||||||
|
category VARCHAR(16) NULL, -- champion | mini | sea
|
||||||
|
type VARCHAR(80) NULL,
|
||||||
|
name VARCHAR(120) NULL,
|
||||||
|
status VARCHAR(16) NULL, -- active | cooldown | dormant
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
boss_up TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
payload JSON NOT NULL, -- the full champ.update object
|
||||||
|
t BIGINT NULL, -- event time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_champs_category (category)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
|
||||||
|
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
|
||||||
|
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
|
||||||
|
-- player). Staff-only data — served on the admin channel, never public.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_pages (
|
||||||
|
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
|
||||||
|
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
|
||||||
|
sender_name VARCHAR(120) NULL,
|
||||||
|
sender_acct VARCHAR(120) NULL,
|
||||||
|
web_id INT NULL, -- linked website user id, if any
|
||||||
|
message TEXT NULL,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
|
||||||
|
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
|
||||||
|
handler VARCHAR(120) NULL,
|
||||||
|
payload JSON NOT NULL, -- the full page.new/updated object
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_pages_handled (handled)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
|
||||||
|
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
|
||||||
|
-- actor object flattened into leader_* columns; the full event is kept in
|
||||||
|
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
|
||||||
|
-- projection into our store so the public Guilds page survives a shard outage.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_guilds (
|
||||||
|
id INT NOT NULL PRIMARY KEY, -- in-game guild id
|
||||||
|
name VARCHAR(120) NULL,
|
||||||
|
abbr VARCHAR(24) NULL,
|
||||||
|
members INT NULL,
|
||||||
|
online INT NULL,
|
||||||
|
alliance VARCHAR(120) NULL,
|
||||||
|
leader_serial VARCHAR(20) NULL,
|
||||||
|
leader_name VARCHAR(120) NULL,
|
||||||
|
leader_acct VARCHAR(120) NULL,
|
||||||
|
leader_web_id INT NULL,
|
||||||
|
payload JSON NOT NULL, -- the full guild.update object
|
||||||
|
t BIGINT NULL, -- event time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_guilds_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
||||||
|
-- city.update (full-state, emitted only on change; there is no remove event since
|
||||||
|
-- the set of cities is fixed). governor / governorElect are actor objects
|
||||||
|
-- flattened into columns; the full event is kept in `payload`. Empty on shards
|
||||||
|
-- that do not run the City Loyalty system.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_governors (
|
||||||
|
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
|
||||||
|
governor_serial VARCHAR(20) NULL,
|
||||||
|
governor_name VARCHAR(120) NULL,
|
||||||
|
governor_acct VARCHAR(120) NULL,
|
||||||
|
governor_web_id INT NULL,
|
||||||
|
elect_serial VARCHAR(20) NULL,
|
||||||
|
elect_name VARCHAR(120) NULL,
|
||||||
|
elect_acct VARCHAR(120) NULL,
|
||||||
|
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
|
||||||
|
candidates INT NULL,
|
||||||
|
auto_pick_at DATETIME NULL,
|
||||||
|
payload JSON NOT NULL, -- the full city.update object
|
||||||
|
t BIGINT NULL, -- event time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Governor term history — the "who governed when" ledger behind the Governors
|
||||||
|
-- board. Captured from day one (history cannot be backfilled) on every observed
|
||||||
|
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
|
||||||
|
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
|
||||||
|
-- COUNT and election phase, not per-candidate tallies, so we record who governed
|
||||||
|
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
|
||||||
|
-- were all the governors of Britain?") reads this table.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_governor_terms (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
city VARCHAR(40) NOT NULL,
|
||||||
|
governor_serial VARCHAR(20) NULL,
|
||||||
|
governor_name VARCHAR(120) NULL,
|
||||||
|
governor_acct VARCHAR(120) NULL,
|
||||||
|
governor_web_id INT NULL,
|
||||||
|
started_at BIGINT NOT NULL, -- term start, epoch ms
|
||||||
|
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
|
||||||
|
votes INT NULL, -- not in the feed (reserved)
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_gov_terms_city (city, started_at),
|
||||||
|
INDEX idx_shard_gov_terms_open (city, ended_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
|
||||||
|
-- latest presence.online aggregate: total count plus per-facet and per-region
|
||||||
|
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
|
||||||
|
-- is the rolled-up headcount the public "Players Online" widget renders. The
|
||||||
|
-- time series, if ever needed, is available from GET /history?kind=presence.online.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_presence (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
count INT NOT NULL DEFAULT 0,
|
||||||
|
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
|
||||||
|
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
|
||||||
|
t BIGINT NULL, -- snapshot time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||||
|
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||||
|
-- Vendor Search gump offers — from outside the game.
|
||||||
|
--
|
||||||
|
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||||
|
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||||
|
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
||||||
|
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
||||||
|
-- them consistent, and an FK would turn a malformed frame into a failed write
|
||||||
|
-- rather than a dropped row.
|
||||||
|
--
|
||||||
|
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||||
|
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||||
|
shop_name VARCHAR(160) NULL,
|
||||||
|
owner_serial VARCHAR(20) NULL,
|
||||||
|
owner_name VARCHAR(64) NULL,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
region VARCHAR(80) NULL,
|
||||||
|
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||||
|
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||||
|
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||||
|
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_vendors_owner (owner_name),
|
||||||
|
INDEX idx_shard_vendors_map (map),
|
||||||
|
INDEX idx_shard_vendors_region (region),
|
||||||
|
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||||
|
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||||
|
INDEX idx_shard_vendors_updated (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||||
|
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||||
|
-- normalized rather than left inside a payload column, and there is no payload
|
||||||
|
-- column on shard_vendors at all.
|
||||||
|
--
|
||||||
|
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||||
|
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||||
|
-- at query time would make the cliloc table a join on the hot path AND make
|
||||||
|
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||||
|
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||||
|
-- re-send an unchanged shop just because the site learned what its items are
|
||||||
|
-- called.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vendor_serial VARCHAR(20) NOT NULL,
|
||||||
|
serial VARCHAR(20) NOT NULL,
|
||||||
|
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||||
|
hue INT NOT NULL DEFAULT 0,
|
||||||
|
amount INT NOT NULL DEFAULT 1,
|
||||||
|
price BIGINT NOT NULL DEFAULT 0,
|
||||||
|
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||||
|
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||||
|
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||||
|
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||||
|
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||||
|
INDEX idx_shard_vendor_items_price (price),
|
||||||
|
INDEX idx_shard_vendor_items_item (item_id),
|
||||||
|
INDEX idx_shard_vendor_items_name (display_name),
|
||||||
|
-- Search filters on name and sorts on price; the composite covers the common
|
||||||
|
-- "cheapest matching X" without a filesort over the whole table.
|
||||||
|
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||||
|
) 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
|
||||||
|
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
|
||||||
|
-- docs/link/v3.md §3 for the contract.
|
||||||
|
--
|
||||||
|
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
|
||||||
|
-- stream whether this feature's kinds fan out over SSE at all (the market
|
||||||
|
-- index ships with this off: no page needs a live firehose of
|
||||||
|
-- whole vendor inventories)
|
||||||
|
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
|
||||||
|
-- `webId` are admin-only always and are rejected here — they are
|
||||||
|
-- not in-game visible and are deliberately not configurable.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
||||||
|
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
||||||
|
stream TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
field_rules JSON NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||||
|
-- live" both answer without touching shard_spawn_points.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||||
|
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||||
|
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||||
|
total INT NOT NULL DEFAULT 0,
|
||||||
|
points INT NOT NULL DEFAULT 0,
|
||||||
|
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||||
|
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||||
|
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||||
|
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||||
|
-- art when this is NULL, which is the normal case.
|
||||||
|
art VARCHAR(255) NULL,
|
||||||
|
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||||
|
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||||
|
INDEX idx_shard_spawn_creatures_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||||
|
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||||
|
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||||
|
x INT NOT NULL,
|
||||||
|
y INT NOT NULL,
|
||||||
|
width INT NOT NULL DEFAULT 0,
|
||||||
|
height INT NOT NULL DEFAULT 0,
|
||||||
|
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||||
|
max_count INT NOT NULL DEFAULT 0,
|
||||||
|
min_delay INT NOT NULL DEFAULT 0,
|
||||||
|
max_delay INT NOT NULL DEFAULT 0,
|
||||||
|
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||||
|
tod_end INT NOT NULL DEFAULT 0,
|
||||||
|
tod_mode INT NOT NULL DEFAULT 0,
|
||||||
|
region VARCHAR(120) NULL,
|
||||||
|
landmark VARCHAR(120) NULL,
|
||||||
|
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||||
|
INDEX idx_shard_spawn_points_facet (facet),
|
||||||
|
INDEX idx_shard_spawn_points_label (label)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- The many-to-many between the two above: one spawner commonly carries several
|
||||||
|
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||||
|
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||||
|
point_id INT NOT NULL,
|
||||||
|
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||||
|
max_count INT NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (point_id, slug),
|
||||||
|
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||||
|
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||||
|
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
type VARCHAR(80) NULL, -- ServUO region class
|
||||||
|
priority INT NOT NULL DEFAULT 0,
|
||||||
|
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||||
|
rects JSON NULL,
|
||||||
|
INDEX idx_shard_regions_facet (facet),
|
||||||
|
INDEX idx_shard_regions_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||||
|
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||||
|
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
grp VARCHAR(120) NULL,
|
||||||
|
x INT NOT NULL,
|
||||||
|
y INT NOT NULL,
|
||||||
|
z INT NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_shard_landmarks_facet (facet),
|
||||||
|
INDEX idx_shard_landmarks_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||||
|
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||||
|
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||||
|
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||||
|
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||||
|
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
facet VARCHAR(40) NOT NULL,
|
||||||
|
x INT NOT NULL,
|
||||||
|
y INT NOT NULL,
|
||||||
|
z INT NOT NULL DEFAULT 0,
|
||||||
|
radius INT NOT NULL DEFAULT 0,
|
||||||
|
label VARCHAR(120) NULL, -- resolved place name
|
||||||
|
INDEX idx_shard_champion_spawns_facet (facet)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- UO's localization table: cliloc id -> display string. Items carry a
|
||||||
|
-- `LabelNumber` rather than a name, so without this the site can only render
|
||||||
|
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
|
||||||
|
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
|
||||||
|
-- was never the missing piece, the table was.
|
||||||
|
--
|
||||||
|
-- Sourced from a file the OPERATOR converts once from their own UO client and
|
||||||
|
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
|
||||||
|
-- is committed, the same rule the spawn atlas and the creature art map follow.
|
||||||
|
-- A shard with no cliloc file configured simply renders item ids, which is what
|
||||||
|
-- it did before this table existed.
|
||||||
|
--
|
||||||
|
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
|
||||||
|
-- property descriptions, and truncating them silently would be worse than
|
||||||
|
-- storing them. Item NAMES are all short — the index that matters for search is
|
||||||
|
-- on the denormalized `shard_vendor_items.display_name`, not here.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_clilocs (
|
||||||
|
number INT NOT NULL PRIMARY KEY,
|
||||||
|
flag SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
text TEXT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
||||||
|
-- file, its sha256, the entry count and the parser version. The boot path
|
||||||
|
-- compares the stored hash against the file on disk and skips the parse when
|
||||||
|
-- they match, which is every restart that did not follow a client patch.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||||
|
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||||
|
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||||
|
-- is behind the committed artifact.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||||
|
-- NOT applied, because it would remove a facet the site currently serves.
|
||||||
|
--
|
||||||
|
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||||
|
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||||
|
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||||
|
-- blocked by it: the site comes up serving the atlas it already had.
|
||||||
|
--
|
||||||
|
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||||
|
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||||
|
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||||
|
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||||
|
--
|
||||||
|
-- `rejected` is remembered against those exact source hashes so a declined
|
||||||
|
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||||
|
-- hashes and asks again.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||||
|
payload JSON NOT NULL, -- source hashes + facet diff
|
||||||
|
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
|
||||||
|
-- fields than the house.decay transition feed shard_houses was built for. Rather
|
||||||
|
-- than a second table for one entity, extend shard_houses: house.update writes the
|
||||||
|
-- registry columns below (owner display name, co-owner/friend counts, placement
|
||||||
|
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
|
||||||
|
-- upsert only touches its own columns, so the two feeds never clobber each other.
|
||||||
|
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
|
||||||
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
|
||||||
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
|
||||||
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
|
||||||
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
|
||||||
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
||||||
|
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
|
||||||
|
-- so the public Houses browser can list registered houses without pulling in rows
|
||||||
|
-- we only ever saw an IDOC transition for.
|
||||||
|
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
|
||||||
|
-- points.board, vendor.listing), so the pinned version an existing install
|
||||||
|
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
|
||||||
|
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
|
||||||
|
-- created before the bump (idempotent, like the other MODIFYs here).
|
||||||
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
||||||
|
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
|
||||||
|
-- must be one-shot: an operator who deliberately pins an older sidecar in
|
||||||
|
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
|
||||||
|
-- it fire once — written after the UPDATE, and on a fresh install (no
|
||||||
|
-- uo_link_config row yet) it is simply written with nothing to update.
|
||||||
|
UPDATE uo_link_config SET protocol = 3
|
||||||
|
WHERE id = 1 AND protocol < 3
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
||||||
@@ -8,44 +8,90 @@
|
|||||||
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
|
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
|
||||||
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
|
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
|
||||||
// at a dead port, so a module that queried at registration time would hang
|
// at a dead port, so a module that queried at registration time would hang
|
||||||
// both. Anything needing a live database belongs in `onBoot`.
|
// both. Everything needing a live database is in `onBoot`.
|
||||||
// 2. **Never resolve what core owns.** This module lives at
|
// 2. **Never resolve what core owns.** This module lives at
|
||||||
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
|
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
|
||||||
// reaches core's `node_modules` and `require('express')` fails outright.
|
// reaches core's `node_modules` and `require('express')` fails outright.
|
||||||
// express and express-validator arrive on `ctx`; so do the database, the
|
// express, express-validator, the database, the logger, the middleware and
|
||||||
// logger, the middleware and the rest of §2.3.
|
// the rest of §2.3 arrive on `ctx` and are re-exported by `./core`.
|
||||||
// 3. **Never reach into core's tree.** No relative path may escape this
|
// 3. **Never reach into core's tree.** No relative path may escape this
|
||||||
// module's root. `scripts/checkImports.js` enforces that in CI (§5.1)
|
// module's root; `scripts/checkImports.js` enforces that in CI (§5.1).
|
||||||
// rather than leaving it to review.
|
|
||||||
//
|
//
|
||||||
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) deliberately
|
// **Require order is load-bearing, and it is why the requires below are inside
|
||||||
// registers NOTHING. The bundle exists, core discovers it, validates it, mounts
|
// the function.** Every ported file reaches core through `./core`, whose members
|
||||||
// its zero routes, serves its client chunk and reports it `started` — which is
|
// resolve `ctx` when called — but a router does `const express = core.express` at
|
||||||
// the whole delivery path proved end to end before a single UO file moves into
|
// its own file scope, which runs the moment it is required. So `core.init(ctx)`
|
||||||
// it. Slice 1 brings the atlas; every slice after that adds registrations here
|
// has to happen before the first `require` of anything under `router/`. Hoisting
|
||||||
// and deletes the matching files from core.
|
// these to the top of the file would break the module with an error about `ctx`
|
||||||
|
// being missing, from a file that never mentions it. Node caches modules, so
|
||||||
|
// requiring here costs nothing after the first call.
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
||||||
* @param {object} api what the module registers (§2.4)
|
* @param {object} api what the module registers (§2.4)
|
||||||
*/
|
*/
|
||||||
module.exports = function register(ctx, api) {
|
module.exports = function register(ctx, api) {
|
||||||
const log = ctx.log()
|
core.init(ctx)
|
||||||
|
|
||||||
// Registrations land here, slice by slice:
|
/* eslint-disable global-require */
|
||||||
|
const publicShard = require('./router/public/shard.router')
|
||||||
|
const publicAtlas = require('./router/public/atlas.router')
|
||||||
|
const adminShard = require('./router/admin/shard.router')
|
||||||
|
const adminUoLink = require('./router/admin/uoLink.router')
|
||||||
|
const playerShard = require('./router/player/shard.router')
|
||||||
|
const usersShardExtension = require('./router/admin/usersShard.router')
|
||||||
|
|
||||||
|
const shardStreams = require('./config/shardStreams')
|
||||||
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
|
const boot = require('./boot')
|
||||||
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
|
const log = core.logger()
|
||||||
|
|
||||||
|
// The five prefixes, exactly the ones `module.json` declares — the loader
|
||||||
|
// compares the two and rejects a mismatch in either direction. Each router
|
||||||
|
// mounts INSIDE its tier, so it structurally cannot reach above its prefix,
|
||||||
|
// and the tier's own gate is already applied: `/admin` sits behind
|
||||||
|
// `noindex, isLoggedIn, requireRole(...)`, `/player` behind
|
||||||
|
// `noindex, requireAuth`, `/public` behind nothing by design.
|
||||||
//
|
//
|
||||||
// api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
|
// The URLs these produce are byte-identical to the ones core served before the
|
||||||
// api.registerExtension('admin.users.detail', usersShardRouter)
|
// extraction (§1.2). That is the whole point of moving the code and not the
|
||||||
// api.registerNotificationStreams(streams)
|
// paths: the shipped Android app calls `POST /api/v1/admin/shard/kick`, and the
|
||||||
// api.registerAnnounceLeg({ leg: 'towncrier', ... })
|
// Discord bot reads `/api/v1/public/shard/*`, and neither knows or needs to
|
||||||
// api.onBoot(async (ctx) => { ... })
|
// know that a module answers now.
|
||||||
// api.onShutdown(async () => { ... })
|
api.registerRoutes({
|
||||||
|
public: { '/shard': publicShard, '/atlas': publicAtlas },
|
||||||
|
admin: { '/shard': adminShard, '/uo-link': adminUoLink },
|
||||||
|
player: { '/shard': playerShard },
|
||||||
|
})
|
||||||
|
|
||||||
|
// The six `/admin/users/:id/shard/*` URLs, which hang off a CORE resource and
|
||||||
|
// therefore cannot be a mount of our own (§1.9). Core declares the slot in
|
||||||
|
// `users.router.js` and we fill it; the router gets `req.params.id` from the
|
||||||
|
// parent via `mergeParams`. Core's own routes on the resource win any path
|
||||||
|
// conflict, which is correct — it owns the user.
|
||||||
|
api.registerExtension('admin.users.detail', usersShardExtension)
|
||||||
|
|
||||||
|
// The push catalog and the news leg. Core kept the push infrastructure and the
|
||||||
|
// announce worker; what it never had was an opinion about *shard* streams or
|
||||||
|
// about talking to a town crier, and those are content (MODULE_SYSTEM.md §1.8).
|
||||||
//
|
//
|
||||||
// `api` is referenced by this log line and nothing else yet, on purpose: an
|
// Seven of these stream ids and the leg id `towncrier` are grandfathered
|
||||||
// entry point that took `api` and never named it would read like an oversight
|
// (§6.5) — they are stored in `notification_subs` and `announce_job_legs.leg`
|
||||||
// rather than a stage of the extraction.
|
// and read by the shipped Android app, so a rename here is a data migration
|
||||||
|
// plus a client break rather than a tidy-up.
|
||||||
|
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||||
|
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||||
|
|
||||||
|
api.onBoot(boot.onBoot)
|
||||||
|
api.onShutdown(boot.onShutdown)
|
||||||
|
|
||||||
log.info('registered', {
|
log.info('registered', {
|
||||||
version: require('../module.json').version,
|
version: require('../module.json').version,
|
||||||
registers: Object.keys(api).length,
|
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
||||||
|
streams: shardStreams.STREAMS.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
392
server/model/shardAtlas/shardAtlas.db.js
Normal file
392
server/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const { query } = core
|
||||||
|
|
||||||
|
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||||
|
// empties and refills all six inside one transaction, and nothing else in the
|
||||||
|
// codebase writes to them. There are no foreign keys, consistent with every
|
||||||
|
// other shard_* table.
|
||||||
|
|
||||||
|
const BATCH = 500
|
||||||
|
|
||||||
|
const ATLAS_TABLES = [
|
||||||
|
'shard_spawn_point_types',
|
||||||
|
'shard_spawn_points',
|
||||||
|
'shard_spawn_creatures',
|
||||||
|
'shard_regions',
|
||||||
|
'shard_landmarks',
|
||||||
|
'shard_champion_spawns',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function insertBatched(conn, sql, rows) {
|
||||||
|
for (let i = 0; i < rows.length; i += BATCH) {
|
||||||
|
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||||
|
}
|
||||||
|
return rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire atlas in one transaction.
|
||||||
|
*
|
||||||
|
* All-or-nothing on purpose: a failed reload must leave the previous atlas
|
||||||
|
* intact rather than a half-loaded world, since a partially-imported atlas is
|
||||||
|
* indistinguishable from a real one to anyone reading it.
|
||||||
|
*
|
||||||
|
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||||
|
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
|
||||||
|
* `DELETE` is irrelevant.
|
||||||
|
*/
|
||||||
|
async function replaceAtlas(atlas, art = {}) {
|
||||||
|
const conn = await core.pool.getConnection()
|
||||||
|
const counts = {}
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction()
|
||||||
|
|
||||||
|
for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`)
|
||||||
|
|
||||||
|
counts.creatures = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||||
|
atlas.creatures.map((c) => [
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.total ?? 0,
|
||||||
|
c.points ?? 0,
|
||||||
|
JSON.stringify(c.facets ?? {}),
|
||||||
|
art[c.slug] ?? null,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.regions = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||||
|
atlas.regions.map((r) => [
|
||||||
|
r.facet,
|
||||||
|
r.name,
|
||||||
|
r.type || null,
|
||||||
|
r.priority ?? 0,
|
||||||
|
r.parent || null,
|
||||||
|
JSON.stringify(r.rects ?? []),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.landmarks = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||||
|
atlas.landmarks.map((l) => [
|
||||||
|
l.facet,
|
||||||
|
l.name,
|
||||||
|
l.group || null,
|
||||||
|
l.x ?? 0,
|
||||||
|
l.y ?? 0,
|
||||||
|
l.z ?? 0,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.champions = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_champion_spawns ' +
|
||||||
|
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||||
|
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
atlas.champions.map((c) => [
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.group || null,
|
||||||
|
c.type || null,
|
||||||
|
c.randomType ? 1 : 0,
|
||||||
|
c.facet,
|
||||||
|
c.x ?? 0,
|
||||||
|
c.y ?? 0,
|
||||||
|
c.z ?? 0,
|
||||||
|
c.radius ?? 0,
|
||||||
|
c.label || null,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||||
|
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||||
|
// for a multi-row insert. Safe because this transaction just emptied the
|
||||||
|
// table and nothing else writes to it.
|
||||||
|
counts.points = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_spawn_points ' +
|
||||||
|
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||||
|
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||||
|
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
atlas.points.map((p, i) => [
|
||||||
|
i + 1,
|
||||||
|
p.facet,
|
||||||
|
p.name,
|
||||||
|
p.x,
|
||||||
|
p.y,
|
||||||
|
p.width ?? 0,
|
||||||
|
p.height ?? 0,
|
||||||
|
p.range ?? 0,
|
||||||
|
p.maxCount ?? 0,
|
||||||
|
p.minDelay ?? 0,
|
||||||
|
p.maxDelay ?? 0,
|
||||||
|
p.todStart ?? 0,
|
||||||
|
p.todEnd ?? 0,
|
||||||
|
p.todMode ?? 0,
|
||||||
|
p.region,
|
||||||
|
p.landmark,
|
||||||
|
p.label || 'Wilderness',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.pointTypes = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||||
|
atlas.pointTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||||
|
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||||
|
[JSON.stringify({ ...atlas.meta, importedCounts: counts })],
|
||||||
|
)
|
||||||
|
|
||||||
|
// A completed import answers whatever was pending.
|
||||||
|
await conn.query('DELETE FROM shard_atlas_pending')
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
return counts
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback().catch(() => {})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMeta() {
|
||||||
|
const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1')
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||||
|
return { ...payload, importedAt: rows[0].imported_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Facet names currently loaded, used to detect a facet disappearing. */
|
||||||
|
async function getFacets() {
|
||||||
|
const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet')
|
||||||
|
return rows.map((row) => row.facet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pending review ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getPending() {
|
||||||
|
const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1')
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||||
|
return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPending(payload, status = 'pending') {
|
||||||
|
return query(
|
||||||
|
'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' +
|
||||||
|
'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' +
|
||||||
|
'detected_at = CURRENT_TIMESTAMP',
|
||||||
|
[status, JSON.stringify(payload)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPending() {
|
||||||
|
return query('DELETE FROM shard_atlas_pending')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||||
|
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||||
|
// touches the sidecar and nothing degrades when the shard is down.
|
||||||
|
//
|
||||||
|
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||||
|
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||||
|
// by facet name, and matching a key means either concatenating the name into a
|
||||||
|
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||||
|
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||||
|
// uses the indexes that already exist.
|
||||||
|
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||||
|
SELECT 1 FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||||
|
WHERE t.slug = c.slug AND p.facet = ?
|
||||||
|
)`
|
||||||
|
|
||||||
|
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||||
|
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||||
|
// minimum token length to break a search for "orc".
|
||||||
|
function creatureWhere({ q, facet }) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (q) {
|
||||||
|
where.push('c.name LIKE ?')
|
||||||
|
params.push(`%${q}%`)
|
||||||
|
}
|
||||||
|
if (facet) {
|
||||||
|
where.push(CREATURE_FACET_EXISTS)
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||||
|
const { sql, params } = creatureWhere({ q, facet })
|
||||||
|
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||||
|
return rows[0] ? Number(rows[0].n) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||||
|
const { sql, params } = creatureWhere({ q, facet })
|
||||||
|
return query(
|
||||||
|
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||||
|
FROM shard_spawn_creatures c
|
||||||
|
${sql}
|
||||||
|
ORDER BY c.total DESC, c.name ASC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCreature(slug) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||||
|
[slug],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a creature spawns, grouped by resolved place.
|
||||||
|
*
|
||||||
|
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||||
|
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||||
|
* point rows in Node.
|
||||||
|
*/
|
||||||
|
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||||
|
const params = [slug]
|
||||||
|
let facetSql = ''
|
||||||
|
if (facet) {
|
||||||
|
facetSql = 'AND p.facet = ?'
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||||
|
FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||||
|
WHERE t.slug = ? ${facetSql}
|
||||||
|
GROUP BY p.facet, p.label
|
||||||
|
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||||
|
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||||
|
const params = [slug]
|
||||||
|
let facetSql = ''
|
||||||
|
if (facet) {
|
||||||
|
facetSql = 'AND p.facet = ?'
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
params.push(limit)
|
||||||
|
return query(
|
||||||
|
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||||
|
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||||
|
p.region, p.landmark, p.label, t.max_count
|
||||||
|
FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||||
|
WHERE t.slug = ? ${facetSql}
|
||||||
|
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every other creature sharing a spawner with this one. */
|
||||||
|
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||||
|
FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||||
|
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||||
|
WHERE t.slug = ?
|
||||||
|
GROUP BY o.slug, c.name
|
||||||
|
ORDER BY shared DESC, c.name ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
[slug, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRegions({ facet = '', q = '' } = {}) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (facet) {
|
||||||
|
where.push('facet = ?')
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.push('name LIKE ?')
|
||||||
|
params.push(`%${q}%`)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT facet, name, type, priority, parent, rects
|
||||||
|
FROM shard_regions
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
ORDER BY facet ASC, name ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (facet) {
|
||||||
|
where.push('facet = ?')
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||||
|
params.push(`%${q}%`, `%${q}%`)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT facet, name, grp, x, y, z
|
||||||
|
FROM shard_landmarks
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listChampions({ facet = '' } = {}) {
|
||||||
|
const params = []
|
||||||
|
let where = ''
|
||||||
|
if (facet) {
|
||||||
|
where = 'WHERE facet = ?'
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||||
|
FROM shard_champion_spawns
|
||||||
|
${where}
|
||||||
|
ORDER BY facet ASC, name ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
replaceAtlas,
|
||||||
|
getMeta,
|
||||||
|
getFacets,
|
||||||
|
getPending,
|
||||||
|
setPending,
|
||||||
|
clearPending,
|
||||||
|
countCreatures,
|
||||||
|
listCreatures,
|
||||||
|
getCreature,
|
||||||
|
listCreaturePlaces,
|
||||||
|
listCreaturePoints,
|
||||||
|
listCreatureCompanions,
|
||||||
|
listRegions,
|
||||||
|
listLandmarks,
|
||||||
|
listChampions,
|
||||||
|
}
|
||||||
491
server/model/shardAtlas/shardAtlas.model.js
Normal file
491
server/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const db = require('./shardAtlas.db')
|
||||||
|
const core = require('../../core')
|
||||||
|
const { settings } = core
|
||||||
|
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||||
|
const {
|
||||||
|
AtlasSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
buildAtlas,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
} = require('../../utils/spawnAtlasSource')
|
||||||
|
const log = require('../../core').logger('shardAtlas')
|
||||||
|
|
||||||
|
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||||
|
//
|
||||||
|
// The tree is the single source of truth. Nothing is precomputed and committed,
|
||||||
|
// because a shard's maps change over its lifetime — facets get added, replaced
|
||||||
|
// or renamed — and a snapshot in the repo would go stale against the world
|
||||||
|
// players actually see. So the atlas is re-derived on every boot.
|
||||||
|
//
|
||||||
|
// Two rules govern the boot path:
|
||||||
|
//
|
||||||
|
// 1. **It never blocks startup.** No configured path, an unreadable path, a
|
||||||
|
// malformed file, a database error — all of it is caught and logged. The
|
||||||
|
// site comes up either way, serving whatever atlas it already had.
|
||||||
|
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
|
||||||
|
// the signature of a half-copied or mid-update tree as much as of a real
|
||||||
|
// map change, and the two are indistinguishable from here. The refresh is
|
||||||
|
// staged for a human instead, and an admin approves or rejects it.
|
||||||
|
//
|
||||||
|
// Everything else — new facets, renamed regions, changed spawns — applies
|
||||||
|
// straight away, because none of it can silently destroy data an operator would
|
||||||
|
// miss.
|
||||||
|
|
||||||
|
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the ServUO tree lives.
|
||||||
|
*
|
||||||
|
* The admin setting wins over the environment so an operator can point the
|
||||||
|
* atlas at a different tree without a redeploy, matching how the rest of the
|
||||||
|
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
|
||||||
|
* remains as the deploy-time default, since the path usually describes a mount
|
||||||
|
* that the deployment sets up.
|
||||||
|
*/
|
||||||
|
async function getServuoPath() {
|
||||||
|
try {
|
||||||
|
const configured = await settings.get(SETTING_KEY)
|
||||||
|
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||||
|
} catch {
|
||||||
|
// Settings unavailable is not fatal — fall through to the env default.
|
||||||
|
}
|
||||||
|
const fromEnv = process.env.SERVUO_PATH
|
||||||
|
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setServuoPath(value, updatedBy = null) {
|
||||||
|
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||||
|
*
|
||||||
|
* Never committed and never shipped — creature sprites come out of the
|
||||||
|
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
|
||||||
|
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
|
||||||
|
* renders text-only.
|
||||||
|
*/
|
||||||
|
// Resolved from ctx.paths.moduleRoot rather than by walking up from __dirname.
|
||||||
|
// The ported default was `../../../db/data`, which pointed at core's tree when
|
||||||
|
// this file lived there and points OUTSIDE server/ now — a path that happens to
|
||||||
|
// resolve is exactly the kind of port bug that survives a green test suite,
|
||||||
|
// because the absent-file branch returns {} and looks like the normal case.
|
||||||
|
function loadArtMap(dir = path.join(core.moduleRoot, 'server', 'data')) {
|
||||||
|
try {
|
||||||
|
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||||
|
if (!fs.existsSync(file)) return {}
|
||||||
|
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||||
|
return map && typeof map === 'object' ? map : {}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('spawn atlas art map could not be read', { error: err.message })
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten each point's types into `shard_spawn_point_types` rows.
|
||||||
|
*
|
||||||
|
* A spawner may legitimately list the same type twice, and the primary key is
|
||||||
|
* (point_id, slug), so duplicates collapse to the larger max rather than
|
||||||
|
* failing the insert.
|
||||||
|
*/
|
||||||
|
function pointTypeRows(points) {
|
||||||
|
const rows = []
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
const bySlug = new Map()
|
||||||
|
for (const entry of point.types ?? []) {
|
||||||
|
const slug = slugify(entry.type)
|
||||||
|
if (slug === '') continue
|
||||||
|
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||||
|
}
|
||||||
|
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAtlas(atlas) {
|
||||||
|
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the atlas from the configured ServUO tree.
|
||||||
|
*
|
||||||
|
* Returns a result describing what happened rather than throwing, so the caller
|
||||||
|
* — including the boot path — can log it and move on:
|
||||||
|
*
|
||||||
|
* `skipped` no path configured
|
||||||
|
* `unavailable` path configured but unreadable / missing required files
|
||||||
|
* `unchanged` source hashes match the loaded atlas; nothing parsed
|
||||||
|
* `imported` parsed and applied
|
||||||
|
* `needsReview` parsed, but a facet would be lost; staged for an admin
|
||||||
|
* `failed` parsed or applied and something went wrong
|
||||||
|
*
|
||||||
|
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||||
|
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Was the loaded atlas built by THIS parser?
|
||||||
|
*
|
||||||
|
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||||
|
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||||
|
*/
|
||||||
|
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||||
|
|
||||||
|
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||||
|
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||||
|
// must not be silently overruled by the configured path the way an env default
|
||||||
|
// would be.
|
||||||
|
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||||
|
if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||||
|
|
||||||
|
let hashes
|
||||||
|
try {
|
||||||
|
hashes = hashSources(root)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AtlasSourceError) {
|
||||||
|
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||||
|
}
|
||||||
|
return { status: 'failed', reason: err.message, path: root }
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await db.getMeta().catch(() => null)
|
||||||
|
const loaded = meta?.source
|
||||||
|
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||||
|
// Only checking the tree would strand an install whose maps never change on
|
||||||
|
// whatever an older build derived — a corrected parse would ship and never
|
||||||
|
// reach the data.
|
||||||
|
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||||
|
return { status: 'unchanged', path: root }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||||
|
// the tree changes again, at which point the hashes differ and it is a new
|
||||||
|
// decision.
|
||||||
|
const pending = await db.getPending().catch(() => null)
|
||||||
|
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
|
||||||
|
return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let atlas
|
||||||
|
try {
|
||||||
|
atlas = buildAtlas(root)
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 'failed', reason: err.message, path: root }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFacets = await db.getFacets().catch(() => [])
|
||||||
|
const incomingFacets = atlas.facets
|
||||||
|
const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
|
||||||
|
const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))
|
||||||
|
|
||||||
|
// Losing a facet is indistinguishable here from a half-copied tree, so it is
|
||||||
|
// staged rather than applied — but startup is never blocked by it.
|
||||||
|
if (removedFacets.length > 0 && !approve) {
|
||||||
|
const summary = {
|
||||||
|
hashes,
|
||||||
|
path: root,
|
||||||
|
currentFacets,
|
||||||
|
incomingFacets,
|
||||||
|
removedFacets,
|
||||||
|
addedFacets,
|
||||||
|
counts: atlas.meta.counts,
|
||||||
|
}
|
||||||
|
await db.setPending(summary, 'pending').catch((err) => {
|
||||||
|
log.warn('could not stage spawn atlas refresh', { error: err.message })
|
||||||
|
})
|
||||||
|
return { status: 'needsReview', ...summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counts = await applyAtlas(atlas)
|
||||||
|
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 'failed', reason: err.message, path: root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin approved a staged refresh: apply it, facet loss and all. */
|
||||||
|
async function approvePending(options = {}) {
|
||||||
|
return refresh({ ...options, approve: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin rejected a staged refresh: keep the current atlas and remember the
|
||||||
|
* decision against those exact source hashes, so it does not re-prompt every
|
||||||
|
* boot. A further change to the tree produces different hashes and asks again.
|
||||||
|
*/
|
||||||
|
async function rejectPending() {
|
||||||
|
const pending = await db.getPending()
|
||||||
|
if (!pending) return { status: 'none' }
|
||||||
|
await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
|
||||||
|
return { status: 'rejected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the admin panel needs to describe atlas state. */
|
||||||
|
async function status({ path: pathOverride = '' } = {}) {
|
||||||
|
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||||
|
const [meta, pending, facets] = await Promise.all([
|
||||||
|
db.getMeta().catch(() => null),
|
||||||
|
db.getPending().catch(() => null),
|
||||||
|
db.getFacets().catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
|
let treeReadable = false
|
||||||
|
let drift = null
|
||||||
|
if (root !== '') {
|
||||||
|
try {
|
||||||
|
const hashes = hashSources(root)
|
||||||
|
treeReadable = true
|
||||||
|
const loaded = meta?.source
|
||||||
|
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||||
|
: null
|
||||||
|
// Same question `refresh` asks: an import picks something up when either
|
||||||
|
// the tree or the parser has moved on.
|
||||||
|
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||||
|
} catch {
|
||||||
|
treeReadable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: root !== '',
|
||||||
|
path: root,
|
||||||
|
treeReadable,
|
||||||
|
drift,
|
||||||
|
facets,
|
||||||
|
importedAt: meta?.importedAt ?? null,
|
||||||
|
counts: meta?.counts ?? null,
|
||||||
|
pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||||
|
* missing tree or a bad file can never stop the site coming up.
|
||||||
|
*/
|
||||||
|
async function refreshOnBoot() {
|
||||||
|
try {
|
||||||
|
const result = await refresh()
|
||||||
|
switch (result.status) {
|
||||||
|
case 'imported':
|
||||||
|
log.info('spawn atlas refreshed from ServUO tree', {
|
||||||
|
...result.counts,
|
||||||
|
added: result.addedFacets,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'needsReview':
|
||||||
|
log.warn(
|
||||||
|
'spawn atlas refresh staged for admin review — a facet would be removed; ' +
|
||||||
|
'the existing atlas is unchanged',
|
||||||
|
{ removed: result.removedFacets, added: result.addedFacets },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'unavailable':
|
||||||
|
log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
|
||||||
|
break
|
||||||
|
case 'failed':
|
||||||
|
log.warn('spawn atlas refresh failed', { reason: result.reason })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('spawn atlas refresh errored', { error: err.message })
|
||||||
|
return { status: 'failed', reason: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||||
|
// than in the controller, for the same reason shardState does it: the column
|
||||||
|
// names are an implementation detail of the import, and the browser contract
|
||||||
|
// should not move when a column is renamed.
|
||||||
|
|
||||||
|
const jsonOr = (value, fallback) => {
|
||||||
|
if (value == null) return fallback
|
||||||
|
if (typeof value !== 'string') return value
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCreature = (row) => ({
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||||
|
// at once); `points` is how many spawners mention it. They answer different
|
||||||
|
// questions and the UI shows both.
|
||||||
|
total: row.total,
|
||||||
|
points: row.points,
|
||||||
|
facets: jsonOr(row.facets, {}),
|
||||||
|
art: row.art || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapePlace = (row) => ({
|
||||||
|
facet: row.facet,
|
||||||
|
label: row.label,
|
||||||
|
spawners: Number(row.spawners) || 0,
|
||||||
|
maxAlive: Number(row.max_alive) || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapePoint = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
facet: row.facet,
|
||||||
|
name: row.name || null,
|
||||||
|
x: row.x,
|
||||||
|
y: row.y,
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
range: row.spawn_range,
|
||||||
|
maxCount: row.max_count,
|
||||||
|
minDelay: row.min_delay,
|
||||||
|
maxDelay: row.max_delay,
|
||||||
|
todStart: row.tod_start,
|
||||||
|
todEnd: row.tod_end,
|
||||||
|
todMode: row.tod_mode,
|
||||||
|
region: row.region || null,
|
||||||
|
landmark: row.landmark || null,
|
||||||
|
label: row.label,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||||
|
* the UI can say "showing 50 of 800" without a second round trip.
|
||||||
|
*/
|
||||||
|
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
db.listCreatures({ q, facet, limit, offset }),
|
||||||
|
db.countCreatures({ q, facet }),
|
||||||
|
])
|
||||||
|
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||||
|
* exists for), the individual spawners, and what else shares those spawners.
|
||||||
|
*
|
||||||
|
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||||
|
*/
|
||||||
|
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||||
|
const row = await db.getCreature(slug)
|
||||||
|
if (!row) return null
|
||||||
|
const [places, pointRows, alsoHere] = await Promise.all([
|
||||||
|
db.listCreaturePlaces(slug, { facet }),
|
||||||
|
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||||
|
db.listCreatureCompanions(slug),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
...shapeCreature(row),
|
||||||
|
places: places.map(shapePlace),
|
||||||
|
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||||
|
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||||
|
// same field a number on the search route and an array here.
|
||||||
|
spawners: pointRows.map(shapePoint),
|
||||||
|
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||||
|
// rather than the world.
|
||||||
|
spawnersTruncated: pointRows.length >= points,
|
||||||
|
alsoHere: alsoHere.map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
shared: Number(r.shared) || 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRegions(opts = {}) {
|
||||||
|
const rows = await db.listRegions(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
facet: r.facet,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type || null,
|
||||||
|
priority: r.priority,
|
||||||
|
parent: r.parent || null,
|
||||||
|
rects: jsonOr(r.rects, []),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLandmarks(opts = {}) {
|
||||||
|
const rows = await db.listLandmarks(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
facet: r.facet,
|
||||||
|
name: r.name,
|
||||||
|
group: r.grp || null,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listChampions(opts = {}) {
|
||||||
|
const rows = await db.listChampions(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
group: r.grp || null,
|
||||||
|
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||||
|
// explicitly rather than making the client infer it from an empty string.
|
||||||
|
type: r.type || null,
|
||||||
|
randomType: !!r.random_type,
|
||||||
|
facet: r.facet,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
radius: r.radius,
|
||||||
|
label: r.label || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is loaded: the facet list, the counts, and when it was imported.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||||
|
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||||
|
* public endpoint; the admin status route carries them instead.
|
||||||
|
*/
|
||||||
|
async function publicMeta() {
|
||||||
|
const [meta, facets] = await Promise.all([
|
||||||
|
db.getMeta().catch(() => null),
|
||||||
|
db.getFacets().catch(() => []),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
importedAt: meta?.importedAt ?? null,
|
||||||
|
generatedAt: meta?.generatedAt ?? null,
|
||||||
|
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||||
|
// page state its own placement accuracy instead of implying it is complete.
|
||||||
|
counts: meta?.counts ?? null,
|
||||||
|
facets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFacets = () => db.getFacets()
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
refresh,
|
||||||
|
refreshOnBoot,
|
||||||
|
approvePending,
|
||||||
|
rejectPending,
|
||||||
|
status,
|
||||||
|
getServuoPath,
|
||||||
|
setServuoPath,
|
||||||
|
pointTypeRows,
|
||||||
|
loadArtMap,
|
||||||
|
SETTING_KEY,
|
||||||
|
searchCreatures,
|
||||||
|
getCreature,
|
||||||
|
listRegions,
|
||||||
|
listLandmarks,
|
||||||
|
listChampions,
|
||||||
|
listFacets,
|
||||||
|
publicMeta,
|
||||||
|
}
|
||||||
110
server/model/shardClilocs/shardClilocs.db.js
Normal file
110
server/model/shardClilocs/shardClilocs.db.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const { query } = core
|
||||||
|
|
||||||
|
// Raw SQL for the cliloc table. `shard_clilocs` is IMPORT-OWNED: `replaceAll`
|
||||||
|
// empties and refills it inside one transaction, and nothing else in the
|
||||||
|
// codebase writes to it. No foreign keys, consistent with every other shard_*
|
||||||
|
// table.
|
||||||
|
|
||||||
|
const BATCH = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire cliloc table in one transaction.
|
||||||
|
*
|
||||||
|
* All-or-nothing on purpose: a failed reload must leave the previous table
|
||||||
|
* intact rather than a half-loaded one, because a partially-imported cliloc
|
||||||
|
* table is indistinguishable from a complete one to anyone reading it — you
|
||||||
|
* would just see some items named and some not, which is also what "no table at
|
||||||
|
* all" looks like.
|
||||||
|
*
|
||||||
|
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||||
|
* commits, which would defeat exactly that guarantee. (The same trap the spawn
|
||||||
|
* atlas import documents; at ~123k rows `DELETE` is still well under a second.)
|
||||||
|
*/
|
||||||
|
async function replaceAll(entries, meta) {
|
||||||
|
const conn = await core.pool.getConnection()
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction()
|
||||||
|
await conn.query('DELETE FROM shard_clilocs')
|
||||||
|
|
||||||
|
// Blank entries are dropped rather than stored. Roughly HALF of a real
|
||||||
|
// cliloc table is empty strings — ids the client reserves and never uses —
|
||||||
|
// and a row that resolves to no name is indistinguishable from no row at
|
||||||
|
// all to every caller. Dropping them halves the table (123,490 → ~67,500)
|
||||||
|
// and, more importantly, makes the binary and text imports converge on
|
||||||
|
// identical content: the binary format carries the blanks explicitly and a
|
||||||
|
// text export may or may not, depending on the tool.
|
||||||
|
//
|
||||||
|
// Later duplicates win. Merging across sources already happened upstream in
|
||||||
|
// `readCliloc`, so in practice this collapses nothing — it is kept because
|
||||||
|
// the plain format permits a repeated id WITHIN one file and the client's
|
||||||
|
// own loader resolves it the same way (its dictionary assignment
|
||||||
|
// overwrites). Without it, a file the game itself would load happily would
|
||||||
|
// fail the batch insert on a primary-key collision.
|
||||||
|
const byNumber = new Map()
|
||||||
|
let blank = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!Number.isInteger(entry.number)) continue
|
||||||
|
if (String(entry.text ?? '').trim() === '') {
|
||||||
|
blank++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byNumber.set(entry.number, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...byNumber.values()].map((e) => [e.number, e.flag ?? 0, e.text])
|
||||||
|
for (let i = 0; i < rows.length; i += BATCH) {
|
||||||
|
await conn.batch('INSERT INTO shard_clilocs (number, flag, text) VALUES (?,?,?)', rows.slice(i, i + BATCH))
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
'INSERT INTO shard_cliloc_meta (id, payload) VALUES (1, ?) ' +
|
||||||
|
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||||
|
[JSON.stringify({ ...meta, count: rows.length })],
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
return { count: rows.length, blank, duplicates: entries.length - blank - rows.length }
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback().catch(() => {})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMeta() {
|
||||||
|
const rows = await query('SELECT payload, imported_at FROM shard_cliloc_meta WHERE id = 1')
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||||
|
return { ...payload, importedAt: rows[0].imported_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a batch of ids.
|
||||||
|
*
|
||||||
|
* Batched rather than one-at-a-time because every caller has a LIST: a character
|
||||||
|
* sheet resolves a dozen equipment ids at once, and a page of marketplace
|
||||||
|
* listings resolves fifty. `IN (...)` with generated placeholders keeps it one
|
||||||
|
* round trip and one parameterized statement.
|
||||||
|
*/
|
||||||
|
async function lookup(numbers) {
|
||||||
|
if (!Array.isArray(numbers) || numbers.length === 0) return []
|
||||||
|
const ids = [...new Set(numbers.filter((n) => Number.isInteger(n)))]
|
||||||
|
if (ids.length === 0) return []
|
||||||
|
const placeholders = ids.map(() => '?').join(',')
|
||||||
|
return query(`SELECT number, text FROM shard_clilocs WHERE number IN (${placeholders})`, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function count() {
|
||||||
|
const rows = await query('SELECT COUNT(*) AS n FROM shard_clilocs')
|
||||||
|
return Number(rows[0]?.n) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
replaceAll,
|
||||||
|
getMeta,
|
||||||
|
lookup,
|
||||||
|
count,
|
||||||
|
}
|
||||||
368
server/model/shardClilocs/shardClilocs.model.js
Normal file
368
server/model/shardClilocs/shardClilocs.model.js
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
const db = require('./shardClilocs.db')
|
||||||
|
const { settings } = require('../../core')
|
||||||
|
const { displayText } = require('../../utils/clilocParse')
|
||||||
|
const {
|
||||||
|
ClilocFormatError,
|
||||||
|
ClilocSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
missingSources,
|
||||||
|
readCliloc,
|
||||||
|
} = require('../../utils/clilocSource')
|
||||||
|
const log = require('../../core').logger('shardClilocs')
|
||||||
|
|
||||||
|
// The cliloc table — UO's id → display-string map, refreshed from a file the
|
||||||
|
// operator converts once from their own client.
|
||||||
|
//
|
||||||
|
// Why the site holds this at all: items on the wire carry a `LabelNumber`, not a
|
||||||
|
// name. `char.profile.equipment` has always sent `cliloc`, and every marketplace
|
||||||
|
// listing sends one too. Without the table the UI can only print `id 1023721`
|
||||||
|
// where the game prints "quarter staff".
|
||||||
|
//
|
||||||
|
// Two rules govern the boot path, both inherited from the spawn atlas:
|
||||||
|
//
|
||||||
|
// 1. **It never blocks startup.** No configured path, an unreadable file, a
|
||||||
|
// wrong-format file, a database error — all caught and logged. The site
|
||||||
|
// comes up either way, serving whatever table it already had (or none, in
|
||||||
|
// which case the UI falls back to item ids exactly as it did before).
|
||||||
|
// 2. **Nothing client-derived is committed.** The table is built from the
|
||||||
|
// operator's own file at a configured path. The repo ships no strings.
|
||||||
|
//
|
||||||
|
// The table is built from a SET of sources — the converted client table plus
|
||||||
|
// every operator-maintained overlay beside it — because shards edit items and
|
||||||
|
// add new ones, and those carry cliloc ids no stock client table has. All of
|
||||||
|
// them are re-read on every boot and hash-gated together, so adding one custom
|
||||||
|
// item never means re-exporting a 5 MB client file. Later sources win.
|
||||||
|
//
|
||||||
|
// That set is also why this has the atlas's escalation, in a lighter form. A
|
||||||
|
// single corrupt file fails the parse loudly, but a source that has simply
|
||||||
|
// VANISHED parses perfectly and imports a table quietly missing everything it
|
||||||
|
// contributed — the same ambiguity (real change vs half-copied mount) the atlas
|
||||||
|
// stages a facet removal for. So a disappearing source is refused and reported
|
||||||
|
// rather than applied.
|
||||||
|
//
|
||||||
|
// It is lighter than the atlas's because it needs to be: the atlas stores a
|
||||||
|
// pending decision in its own table and adds approve/reject endpoints, whereas
|
||||||
|
// here the decision is a single boolean an admin passes to the import they were
|
||||||
|
// already going to run. Re-parsing at approval time — the property that makes
|
||||||
|
// the atlas store only the decision — is automatic when there is nothing stored.
|
||||||
|
|
||||||
|
const SETTING_KEY = 'cliloc_client_path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the converted cliloc file lives.
|
||||||
|
*
|
||||||
|
* The admin setting wins over the environment so an operator can repoint it
|
||||||
|
* without a redeploy, matching how the rest of the shard integration is
|
||||||
|
* admin-managed rather than env-configured. `UO_CLIENT_PATH` remains as the
|
||||||
|
* deploy-time default, since the path usually describes a mount the deployment
|
||||||
|
* sets up.
|
||||||
|
*/
|
||||||
|
async function getClientPath() {
|
||||||
|
try {
|
||||||
|
const configured = await settings.get(SETTING_KEY)
|
||||||
|
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||||
|
} catch {
|
||||||
|
// Settings unavailable is not fatal — fall through to the env default.
|
||||||
|
}
|
||||||
|
const fromEnv = process.env.UO_CLIENT_PATH
|
||||||
|
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setClientPath(value, updatedBy = null) {
|
||||||
|
const result = await settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||||
|
invalidate()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Was the loaded table built by THIS parser? */
|
||||||
|
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the cliloc table from the configured file.
|
||||||
|
*
|
||||||
|
* Returns a result describing what happened rather than throwing, so the caller
|
||||||
|
* — including the boot path — can log it and move on:
|
||||||
|
*
|
||||||
|
* `skipped` no path configured
|
||||||
|
* `unavailable` path configured but missing / unreadable / not a cliloc file
|
||||||
|
* `unchanged` source hashes match the loaded table; nothing parsed
|
||||||
|
* `imported` parsed and applied
|
||||||
|
* `needsReview` a previously-present source has vanished; NOT applied
|
||||||
|
* `failed` parsed or applied and something went wrong
|
||||||
|
*
|
||||||
|
* `force` skips the hash check (an admin asking for a reimport). `approve`
|
||||||
|
* additionally accepts a vanished source.
|
||||||
|
*/
|
||||||
|
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||||
|
// An explicit override wins outright — a one-off "use this file", which must
|
||||||
|
// not be silently overruled by the configured path the way an env default is.
|
||||||
|
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||||
|
if (configured === '') return { status: 'skipped', reason: 'no cliloc path configured' }
|
||||||
|
|
||||||
|
let fingerprint
|
||||||
|
try {
|
||||||
|
fingerprint = hashSources(configured)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ClilocSourceError) {
|
||||||
|
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||||
|
}
|
||||||
|
return { status: 'failed', reason: err.message, path: configured }
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await db.getMeta().catch(() => null)
|
||||||
|
|
||||||
|
// Two things make a loaded table stale: any source changed, or the PARSER did.
|
||||||
|
// Only checking the sources would strand an install whose client never patches
|
||||||
|
// on whatever an older build derived.
|
||||||
|
if (!force && sameSources(fingerprint.hashes, meta?.hashes) && currentParser(meta)) {
|
||||||
|
return {
|
||||||
|
status: 'unchanged',
|
||||||
|
path: configured,
|
||||||
|
file: fingerprint.file,
|
||||||
|
count: meta.count ?? null,
|
||||||
|
customCount: fingerprint.customCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that was there last import and is not there now is refused, not
|
||||||
|
// applied — an unmounted volume and a deliberate deletion look identical from
|
||||||
|
// here, and the wrong guess silently drops every name that file contributed.
|
||||||
|
const gone = missingSources(fingerprint.hashes, meta?.hashes)
|
||||||
|
if (gone.length > 0 && !approve) {
|
||||||
|
return {
|
||||||
|
status: 'needsReview',
|
||||||
|
reason: `${gone.length} previously-loaded cliloc source(s) are missing; the existing table is unchanged`,
|
||||||
|
missingSources: gone,
|
||||||
|
path: configured,
|
||||||
|
file: fingerprint.file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = readCliloc(configured)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ClilocFormatError || err instanceof ClilocSourceError) {
|
||||||
|
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||||
|
}
|
||||||
|
return { status: 'failed', reason: err.message, path: configured }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const applied = await db.replaceAll(parsed.entries, parsed.source)
|
||||||
|
invalidate()
|
||||||
|
return {
|
||||||
|
status: 'imported',
|
||||||
|
path: configured,
|
||||||
|
file: parsed.source.file,
|
||||||
|
count: applied.count,
|
||||||
|
parsed: parsed.entries.length,
|
||||||
|
blank: applied.blank,
|
||||||
|
// Per-source breakdown: how many entries each file contributed and how
|
||||||
|
// many of them overrode something already merged. An operator who adds an
|
||||||
|
// overlay wants to see it took effect, and "overrode: 0" on a file meant
|
||||||
|
// to re-label stock items says it did not.
|
||||||
|
sources: parsed.source.sources,
|
||||||
|
acceptedMissing: gone.length > 0 ? gone : undefined,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 'failed', reason: err.message, path: configured }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||||
|
* missing or malformed cliloc file can never stop the site coming up.
|
||||||
|
*/
|
||||||
|
async function refreshOnBoot() {
|
||||||
|
try {
|
||||||
|
const result = await refresh()
|
||||||
|
switch (result.status) {
|
||||||
|
case 'imported':
|
||||||
|
log.info('cliloc table refreshed', {
|
||||||
|
file: result.file,
|
||||||
|
count: result.count,
|
||||||
|
overlays: (result.sources || []).filter((s) => s.kind === 'custom').length,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'needsReview':
|
||||||
|
log.warn(
|
||||||
|
'cliloc refresh staged for admin review — a previously-loaded source is missing; ' +
|
||||||
|
'the existing table is unchanged',
|
||||||
|
{ missing: result.missingSources },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'unavailable':
|
||||||
|
// Deliberately a warning, not an error: an operator who has not supplied
|
||||||
|
// a cliloc file is in a supported state (the UI shows item ids), and the
|
||||||
|
// most common cause — pointing at the client's own compressed file —
|
||||||
|
// needs the reason spelled out rather than a stack trace.
|
||||||
|
log.warn('cliloc source unavailable (item names will show as ids)', {
|
||||||
|
reason: result.reason,
|
||||||
|
code: result.code,
|
||||||
|
path: result.path,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'failed':
|
||||||
|
log.warn('cliloc refresh failed', { reason: result.reason })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('cliloc refresh errored', { error: err.message })
|
||||||
|
return { status: 'failed', reason: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the admin panel needs to describe cliloc state. */
|
||||||
|
async function status({ path: pathOverride = '' } = {}) {
|
||||||
|
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||||
|
const meta = await db.getMeta().catch(() => null)
|
||||||
|
const loaded = await db.count().catch(() => 0)
|
||||||
|
|
||||||
|
let fileReadable = false
|
||||||
|
let file = null
|
||||||
|
let drift = null
|
||||||
|
let problem = null
|
||||||
|
let code = null
|
||||||
|
let sources = []
|
||||||
|
let missing = []
|
||||||
|
if (configured !== '') {
|
||||||
|
try {
|
||||||
|
const fingerprint = hashSources(configured)
|
||||||
|
fileReadable = true
|
||||||
|
file = fingerprint.file
|
||||||
|
sources = Object.keys(fingerprint.hashes)
|
||||||
|
missing = missingSources(fingerprint.hashes, meta?.hashes)
|
||||||
|
// A compressed file is readable but not importable, and the panel has to
|
||||||
|
// say so HERE — otherwise pointing at an unconverted client directory
|
||||||
|
// reports a healthy file with pending drift ("ready to import") and the
|
||||||
|
// operator only finds out when the import fails. `drift` stays null
|
||||||
|
// because comparing hashes with an unusable file answers nothing.
|
||||||
|
if (fingerprint.compressed) {
|
||||||
|
problem =
|
||||||
|
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
||||||
|
'Convert it to the plain format first — see docs/website/CLILOCS.md.'
|
||||||
|
code = 'COMPRESSED'
|
||||||
|
} else {
|
||||||
|
drift = !sameSources(fingerprint.hashes, meta?.hashes) || !currentParser(meta)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
fileReadable = false
|
||||||
|
problem = err.message
|
||||||
|
code = err.code ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: configured !== '',
|
||||||
|
path: configured,
|
||||||
|
file,
|
||||||
|
fileReadable,
|
||||||
|
problem,
|
||||||
|
code,
|
||||||
|
drift,
|
||||||
|
count: loaded,
|
||||||
|
// Every source found now (base first, then overlays), what each contributed
|
||||||
|
// at the last import, and any that have since vanished — which is the state
|
||||||
|
// an import will refuse without `approve`.
|
||||||
|
sources,
|
||||||
|
loadedSources: meta?.sources ?? null,
|
||||||
|
missingSources: missing,
|
||||||
|
importedAt: meta?.importedAt ?? null,
|
||||||
|
sourceBytes: meta?.bytes ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lookup ─────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Resolution happens SERVER-SIDE, not in the browser. Two reasons: the table is
|
||||||
|
// ~123k rows and shipping it to a client would dwarf every page that uses it,
|
||||||
|
// and the Android app consumes the same JSON and would otherwise need its own
|
||||||
|
// copy. Callers get names, not ids-plus-a-table.
|
||||||
|
|
||||||
|
// A small write-through cache in front of the table. Item ids repeat heavily —
|
||||||
|
// one page of listings is mostly the same few hundred clilocs, and a character
|
||||||
|
// sheet re-resolves the same gear on every view — so this turns the steady state
|
||||||
|
// into zero queries. Capped so a pathological caller cannot grow it without
|
||||||
|
// bound; on overflow it is dropped wholesale rather than evicted entry-by-entry,
|
||||||
|
// which is cheap and correct for a table that only changes on reimport.
|
||||||
|
const CACHE_MAX = 20000
|
||||||
|
let cache = new Map()
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
cache = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a batch of cliloc ids to display strings.
|
||||||
|
*
|
||||||
|
* Returns a `Map<number, string>` holding only the ids that resolved to
|
||||||
|
* something displayable — an id with no row, or one whose text is nothing but
|
||||||
|
* interpolated arguments we do not have, is simply absent. Callers fall back to
|
||||||
|
* whatever they had (the item id), so "missing" and "unnamed" collapse into one
|
||||||
|
* branch at the call site.
|
||||||
|
*
|
||||||
|
* Never throws: a cliloc lookup is decoration on someone's character sheet, and
|
||||||
|
* a database blip must not fail the sheet.
|
||||||
|
*/
|
||||||
|
async function resolveMany(numbers) {
|
||||||
|
const out = new Map()
|
||||||
|
if (!Array.isArray(numbers)) return out
|
||||||
|
|
||||||
|
const wanted = [...new Set(numbers.filter((n) => Number.isInteger(n) && n > 0))]
|
||||||
|
if (wanted.length === 0) return out
|
||||||
|
|
||||||
|
const missing = []
|
||||||
|
for (const number of wanted) {
|
||||||
|
if (cache.has(number)) {
|
||||||
|
const hit = cache.get(number)
|
||||||
|
if (hit !== '') out.set(number, hit)
|
||||||
|
} else {
|
||||||
|
missing.push(number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
try {
|
||||||
|
const rows = await db.lookup(missing)
|
||||||
|
const found = new Map(rows.map((r) => [Number(r.number), displayText(r.text)]))
|
||||||
|
if (cache.size + missing.length > CACHE_MAX) invalidate()
|
||||||
|
for (const number of missing) {
|
||||||
|
// Cache the miss too ('' meaning "no usable name"), so an id absent from
|
||||||
|
// the table does not re-query on every page view.
|
||||||
|
const text = found.get(number) ?? ''
|
||||||
|
cache.set(number, text)
|
||||||
|
if (text !== '') out.set(number, text)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('cliloc lookup failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-id convenience. Returns `null` when there is no usable name. */
|
||||||
|
async function resolve(number) {
|
||||||
|
const found = await resolveMany([number])
|
||||||
|
return found.get(number) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SETTING_KEY,
|
||||||
|
getClientPath,
|
||||||
|
setClientPath,
|
||||||
|
refresh,
|
||||||
|
refreshOnBoot,
|
||||||
|
status,
|
||||||
|
resolveMany,
|
||||||
|
resolve,
|
||||||
|
invalidate,
|
||||||
|
}
|
||||||
46
server/model/shardEvents/shardEvents.db.js
Normal file
46
server/model/shardEvents/shardEvents.db.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const { query } = require('../../core')
|
||||||
|
|
||||||
|
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
|
||||||
|
// backfill overlap) is silently skipped rather than duplicated. Returns true if
|
||||||
|
// a new row was actually inserted.
|
||||||
|
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
|
||||||
|
)
|
||||||
|
return res.affectedRows > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events, newest first. Filter by a single `kind`, or an allowlist of
|
||||||
|
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||||
|
// staff/sensitive kinds. limit is clamped by the model.
|
||||||
|
async function list({ kind, kinds, limit }) {
|
||||||
|
// An allowlist that resolved to NOTHING means "serve nothing" — never "serve
|
||||||
|
// everything". Falling through to the unfiltered query below would have turned
|
||||||
|
// a fully-gated visibility config into a full dump of the event log, staff
|
||||||
|
// audit and cheat detections included.
|
||||||
|
if (kinds && kinds.length === 0) return []
|
||||||
|
if (kinds && kinds.length) {
|
||||||
|
const placeholders = kinds.map(() => '?').join(', ')
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
|
||||||
|
[...kinds, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (kind) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
|
||||||
|
[kind, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT id, kind, t, boot_id, payload, created_at
|
||||||
|
FROM shard_events ORDER BY t DESC LIMIT ?`,
|
||||||
|
[limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { insertIgnore, list }
|
||||||
61
server/model/shardEvents/shardEvents.model.js
Normal file
61
server/model/shardEvents/shardEvents.model.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Append-only shard event log. The WS ingest dispatcher calls append() for the
|
||||||
|
// notable kinds; the public/admin read endpoints call list(). The DB layer only
|
||||||
|
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
|
||||||
|
// WS-reconnect backfill.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const db = require('./shardEvents.db')
|
||||||
|
|
||||||
|
const MAX_LIMIT = 1000
|
||||||
|
const DEFAULT_LIMIT = 100
|
||||||
|
|
||||||
|
// Stable stringify — keys sorted — so the dedupe hash is independent of the
|
||||||
|
// property order the sidecar happened to serialize with.
|
||||||
|
function stableStringify(value) {
|
||||||
|
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||||
|
const keys = Object.keys(value).sort()
|
||||||
|
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
||||||
|
return `{${entries.join(',')}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars.
|
||||||
|
// This is a content fingerprint for idempotent INSERT IGNORE, not a security value,
|
||||||
|
// but we use SHA-256 rather than SHA-1 anyway; the truncation keeps it inside the
|
||||||
|
// CHAR(40) column (160 bits is ample collision resistance for dedupe). Two identical
|
||||||
|
// events (same kind, same timestamp, same body) collapse to one row.
|
||||||
|
function dedupeKey(kind, t, payload) {
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${kind}|${t}|${stableStringify(payload)}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append one event. Returns true if a new row was inserted (false = deduped).
|
||||||
|
async function append({ kind, t, bootId, payload }) {
|
||||||
|
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit) {
|
||||||
|
const n = Number(limit)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
|
||||||
|
return Math.min(Math.floor(n), MAX_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events, newest first. Each row's JSON payload is parsed back to an
|
||||||
|
// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
|
||||||
|
async function list({ kind, kinds, limit } = {}) {
|
||||||
|
const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
t: row.t,
|
||||||
|
bootId: row.boot_id || null,
|
||||||
|
// mariadb returns JSON columns as strings on some versions; parse defensively.
|
||||||
|
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { append, list, dedupeKey }
|
||||||
42
server/model/shardLinks/shardLinks.db.js
Normal file
42
server/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const { query } = require('../../core')
|
||||||
|
|
||||||
|
const COLS = 'account, user_id, char_name, linked_at'
|
||||||
|
|
||||||
|
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||||
|
// user (the sidecar already treats /link/confirm as authoritative).
|
||||||
|
async function upsert({ account, userId, charName }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||||
|
[account, userId, charName || null],
|
||||||
|
)
|
||||||
|
return getByAccount(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getByAccount(account) {
|
||||||
|
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const listByUser = (userId) =>
|
||||||
|
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||||
|
|
||||||
|
async function isOwnedBy(account, userId) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||||
|
[account, userId],
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (account, userId) =>
|
||||||
|
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||||
|
|
||||||
|
// Drop the mirror for an account regardless of which user held it — used to
|
||||||
|
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||||
|
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||||
|
const removeByAccount = (account) =>
|
||||||
|
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||||
|
|
||||||
|
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||||
37
server/model/shardLinks/shardLinks.model.js
Normal file
37
server/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||||
|
// authoritative link (it tags the game account on /link/confirm); this model
|
||||||
|
// records it locally so the player portal can list links and enforce ownership.
|
||||||
|
|
||||||
|
const db = require('./shardLinks.db')
|
||||||
|
|
||||||
|
function toSafe(row) {
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
account: row.account,
|
||||||
|
userId: row.user_id,
|
||||||
|
charName: row.char_name || null,
|
||||||
|
linkedAt: row.linked_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function link({ account, userId, charName }) {
|
||||||
|
return toSafe(await db.upsert({ account, userId, charName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listForUser(userId) {
|
||||||
|
const rows = await db.listByUser(userId)
|
||||||
|
return rows.map(toSafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||||
|
|
||||||
|
async function getByAccount(account) {
|
||||||
|
return toSafe(await db.getByAccount(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlink = (account, userId) => db.remove(account, userId)
|
||||||
|
|
||||||
|
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||||
|
const removeByAccount = (account) => db.removeByAccount(account)
|
||||||
|
|
||||||
|
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||||
301
server/model/shardMarket/shardMarket.db.js
Normal file
301
server/model/shardMarket/shardMarket.db.js
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const { query } = core
|
||||||
|
|
||||||
|
// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
|
||||||
|
//
|
||||||
|
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
|
||||||
|
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
|
||||||
|
// writes to either. No foreign keys, consistent with every other shard_* table.
|
||||||
|
|
||||||
|
// Insert batch size for one vendor's listings. A shop is capped at
|
||||||
|
// MarketMaxListings (250 by default) on the shard side, so in practice this is
|
||||||
|
// one batch — it exists for the operator who raised that cap.
|
||||||
|
const BATCH = 500
|
||||||
|
|
||||||
|
// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
|
||||||
|
// query — they are special to LIKE itself — so a search for "50% off" would
|
||||||
|
// otherwise match everything containing "50" and a search for "_" would match
|
||||||
|
// every single-character name. Escaped with a backslash, which is MariaDB's
|
||||||
|
// default LIKE escape (no ESCAPE clause needed).
|
||||||
|
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace one vendor's whole row and listing set, in one transaction.
|
||||||
|
*
|
||||||
|
* Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
|
||||||
|
* that vendor: the shard's sweep only emits a shop whose contents, prices or
|
||||||
|
* location moved, and when it does it sends the whole shop. Reconciling it item
|
||||||
|
* by item would be more code for the same result and would leave sold items
|
||||||
|
* behind on any path the reconciliation missed.
|
||||||
|
*
|
||||||
|
* All-or-nothing matters here for a specific reason: the two writes are "the
|
||||||
|
* shop" and "what is in it", and a failure between them leaves a shop advertising
|
||||||
|
* an inventory it no longer has (or none at all) — visibly wrong on the page, and
|
||||||
|
* indistinguishable from a genuinely empty shop.
|
||||||
|
*/
|
||||||
|
async function replaceVendor(vendor, items) {
|
||||||
|
const conn = await core.pool.getConnection()
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction()
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO shard_vendors
|
||||||
|
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||||
|
item_count, item_total, truncated, t)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
|
||||||
|
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
||||||
|
z = VALUES(z), region = VALUES(region), house = VALUES(house),
|
||||||
|
item_count = VALUES(item_count), item_total = VALUES(item_total),
|
||||||
|
truncated = VALUES(truncated), t = VALUES(t),
|
||||||
|
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
|
||||||
|
-- MariaDB does not fire that when every column is written back
|
||||||
|
-- unchanged, and a shop that is re-published identically is still
|
||||||
|
-- FRESHLY CONFIRMED. Without this the staleness banner would age a
|
||||||
|
-- perfectly current shop forever.
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[
|
||||||
|
vendor.serial,
|
||||||
|
vendor.shopName ?? null,
|
||||||
|
vendor.ownerSerial ?? null,
|
||||||
|
vendor.ownerName ?? null,
|
||||||
|
vendor.map ?? null,
|
||||||
|
Number.isFinite(vendor.x) ? vendor.x : null,
|
||||||
|
Number.isFinite(vendor.y) ? vendor.y : null,
|
||||||
|
Number.isFinite(vendor.z) ? vendor.z : null,
|
||||||
|
vendor.region ?? null,
|
||||||
|
vendor.house ?? null,
|
||||||
|
items.length,
|
||||||
|
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
||||||
|
vendor.truncated ? 1 : 0,
|
||||||
|
Number.isFinite(vendor.t) ? vendor.t : null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])
|
||||||
|
|
||||||
|
const rows = items.map((i) => [
|
||||||
|
vendor.serial,
|
||||||
|
i.serial,
|
||||||
|
i.itemId,
|
||||||
|
i.hue,
|
||||||
|
i.amount,
|
||||||
|
i.price,
|
||||||
|
i.name,
|
||||||
|
i.cliloc,
|
||||||
|
i.displayName,
|
||||||
|
i.child ? 1 : 0,
|
||||||
|
])
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i += BATCH) {
|
||||||
|
await conn.batch(
|
||||||
|
`INSERT INTO shard_vendor_items
|
||||||
|
(vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
rows.slice(i, i + BATCH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
return { items: rows.length }
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback().catch(() => {})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop one vendor and its listings (vendor.listing.remove). */
|
||||||
|
async function removeVendor(serial) {
|
||||||
|
const conn = await core.pool.getConnection()
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction()
|
||||||
|
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
|
||||||
|
await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
|
||||||
|
await conn.commit()
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback().catch(() => {})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search ─────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
|
||||||
|
// kryss and for how much" is the question, and answering it per vendor would make
|
||||||
|
// the caller flatten the shops back out. The vendor's columns ride along on the
|
||||||
|
// join so a result row is self-contained.
|
||||||
|
|
||||||
|
function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
|
||||||
|
const where = ['i.price > 0']
|
||||||
|
const params = []
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
// Both the resolved display name and the item's own literal, because an item
|
||||||
|
// with a player-set name (most of what is actually worth searching for on a
|
||||||
|
// player-run shard) may have a generic cliloc.
|
||||||
|
where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
|
||||||
|
params.push(likeTerm(q), likeTerm(q))
|
||||||
|
}
|
||||||
|
if (Number.isFinite(minPrice)) {
|
||||||
|
where.push('i.price >= ?')
|
||||||
|
params.push(minPrice)
|
||||||
|
}
|
||||||
|
if (Number.isFinite(maxPrice)) {
|
||||||
|
where.push('i.price <= ?')
|
||||||
|
params.push(maxPrice)
|
||||||
|
}
|
||||||
|
if (Number.isFinite(itemId)) {
|
||||||
|
where.push('i.item_id = ?')
|
||||||
|
params.push(itemId)
|
||||||
|
}
|
||||||
|
if (map) {
|
||||||
|
where.push('v.map = ?')
|
||||||
|
params.push(map)
|
||||||
|
}
|
||||||
|
if (region) {
|
||||||
|
where.push('v.region = ?')
|
||||||
|
params.push(region)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sql: `WHERE ${where.join(' AND ')}`, params }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelisted, because this interpolates into the statement. `recent` sorts by
|
||||||
|
// the vendor's freshness, which is the only way to see what has just been listed
|
||||||
|
// on a shard whose sweep is minutes wide.
|
||||||
|
const SORTS = {
|
||||||
|
price_asc: 'i.price ASC, i.id ASC',
|
||||||
|
price_desc: 'i.price DESC, i.id ASC',
|
||||||
|
recent: 'v.updated_at DESC, i.id ASC',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
|
||||||
|
const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
|
||||||
|
const order = SORTS[sort] || SORTS.price_asc
|
||||||
|
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
|
||||||
|
v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
|
||||||
|
v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
|
||||||
|
FROM shard_vendor_items i
|
||||||
|
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||||
|
${sql}
|
||||||
|
ORDER BY ${order}
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
)
|
||||||
|
|
||||||
|
const counted = await query(
|
||||||
|
`SELECT COUNT(*) AS n
|
||||||
|
FROM shard_vendor_items i
|
||||||
|
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||||
|
${sql}`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { rows, total: Number(counted[0]?.n) || 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVendor(serial) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||||
|
item_count, item_total, truncated, t, updated_at
|
||||||
|
FROM shard_vendors WHERE serial = ?`,
|
||||||
|
[serial],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listVendorItems(serial, { limit, offset }) {
|
||||||
|
return query(
|
||||||
|
`SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
|
||||||
|
FROM shard_vendor_items
|
||||||
|
WHERE vendor_serial = ?
|
||||||
|
ORDER BY price ASC, id ASC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[serial, limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the market page's header needs: how big the index is, and how stale it may
|
||||||
|
* be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
|
||||||
|
* be a full cycle behind, and the page says so rather than implying live prices.
|
||||||
|
*/
|
||||||
|
async function meta() {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
|
||||||
|
FROM shard_vendors`,
|
||||||
|
)
|
||||||
|
const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
|
||||||
|
return {
|
||||||
|
vendors: Number(rows[0]?.vendors) || 0,
|
||||||
|
items: Number(items[0]?.n) || 0,
|
||||||
|
staleAt: rows[0]?.stale_at || null,
|
||||||
|
freshAt: rows[0]?.fresh_at || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The distinct facets and regions holding vendors — drives the page's filters. */
|
||||||
|
async function listPlaces() {
|
||||||
|
const maps = await query(
|
||||||
|
'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
|
||||||
|
)
|
||||||
|
const regions = await query(
|
||||||
|
'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
|
||||||
|
)
|
||||||
|
return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of listings whose name still needs resolving, for the bulk pass that
|
||||||
|
* runs after a cliloc import.
|
||||||
|
*
|
||||||
|
* Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
|
||||||
|
* scanning, and an OFFSET walk over a table being rewritten skips rows. Every
|
||||||
|
* row with a cliloc is re-read, not just the unresolved ones, because an import
|
||||||
|
* can also CHANGE a name — a shard overlay relabelling a stock item is the whole
|
||||||
|
* reason overlays exist.
|
||||||
|
*/
|
||||||
|
async function listResolvableItems(after, limit) {
|
||||||
|
return query(
|
||||||
|
`SELECT id, cliloc, name, display_name
|
||||||
|
FROM shard_vendor_items
|
||||||
|
WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ?`,
|
||||||
|
[after, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write back a batch of re-resolved display names. */
|
||||||
|
async function updateDisplayNames(pairs) {
|
||||||
|
if (pairs.length === 0) return 0
|
||||||
|
const conn = await core.pool.getConnection()
|
||||||
|
try {
|
||||||
|
await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
|
||||||
|
return pairs.length
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
replaceVendor,
|
||||||
|
removeVendor,
|
||||||
|
searchListings,
|
||||||
|
getVendor,
|
||||||
|
listVendorItems,
|
||||||
|
meta,
|
||||||
|
listPlaces,
|
||||||
|
listResolvableItems,
|
||||||
|
updateDisplayNames,
|
||||||
|
likeTerm,
|
||||||
|
}
|
||||||
329
server/model/shardMarket/shardMarket.model.js
Normal file
329
server/model/shardMarket/shardMarket.model.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
// ── Player-vendor market index (Protocol 3.0 vendor.listing) ───────────────
|
||||||
|
//
|
||||||
|
// The shard-wide shop index: what every player vendor is selling, for how much,
|
||||||
|
// and where it is standing. This is the website's half of the search the in-game
|
||||||
|
// Vendor Search gump offers — the same data, the same opt-out, reachable without
|
||||||
|
// logging in to the game.
|
||||||
|
//
|
||||||
|
// Ingest is per-vendor and authoritative: the shard's round-robin sweep emits one
|
||||||
|
// `vendor.listing` frame per shop whose contents, prices or location moved, and
|
||||||
|
// the frame is the whole shop (see docs/link/v3.md §8 and BridgeMarket.cs). This
|
||||||
|
// module normalizes it into shard_vendors + shard_vendor_items and, crucially,
|
||||||
|
// resolves each listing's cliloc to a DISPLAY NAME on the way in — a search for
|
||||||
|
// "kryss" is a search over names, and the shard only ever sends numbers.
|
||||||
|
|
||||||
|
const db = require('./shardMarket.db')
|
||||||
|
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||||
|
const log = require('../../core').logger('shard-market')
|
||||||
|
|
||||||
|
// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
|
||||||
|
// trusted, but it is a separately-versioned component: a frame from a plugin
|
||||||
|
// whose cap was raised (or a shard running modified scripts) must not be able to
|
||||||
|
// turn one ingest into an unbounded transaction.
|
||||||
|
const MAX_ITEMS_PER_VENDOR = 5000
|
||||||
|
|
||||||
|
// Column widths in schema.sql. Truncating here rather than letting MariaDB do it
|
||||||
|
// keeps the behavior the same in strict mode, where an over-length value is an
|
||||||
|
// ERROR and would fail the whole vendor rather than shortening one name.
|
||||||
|
const MAX_NAME = 160
|
||||||
|
const MAX_SHOP = 160
|
||||||
|
const MAX_OWNER = 64
|
||||||
|
const MAX_MAP = 40
|
||||||
|
const MAX_REGION = 80
|
||||||
|
const MAX_SERIAL = 20
|
||||||
|
|
||||||
|
const clip = (value, max) => {
|
||||||
|
if (value == null) return null
|
||||||
|
const s = String(value)
|
||||||
|
return s.length > max ? s.slice(0, max) : s
|
||||||
|
}
|
||||||
|
|
||||||
|
const int = (value, fallback = 0) => {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten one `vendor.listing` frame into the row shapes the DB layer wants.
|
||||||
|
*
|
||||||
|
* `location` arrives as a nested object rather than flat map/x/y/region, and that
|
||||||
|
* shape is load-bearing rather than cosmetic: the visibility projection matches
|
||||||
|
* literal JSON keys, so ONE `market.location` rule can hide a vendor's
|
||||||
|
* whereabouts only if `location` is a single key on both the live frame and the
|
||||||
|
* stored read model. Flattening it here for storage and re-nesting it on read is
|
||||||
|
* what keeps that true on both paths.
|
||||||
|
*
|
||||||
|
* Exported for tests — it is the part with rules in it, and it is pure.
|
||||||
|
*/
|
||||||
|
function flattenFrame(ev) {
|
||||||
|
const loc = (ev && ev.location) || {}
|
||||||
|
return {
|
||||||
|
serial: clip(ev.serial, MAX_SERIAL),
|
||||||
|
shopName: clip(ev.shopName, MAX_SHOP),
|
||||||
|
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
||||||
|
ownerName: clip(ev.ownerName, MAX_OWNER),
|
||||||
|
map: clip(loc.map, MAX_MAP),
|
||||||
|
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
|
||||||
|
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
|
||||||
|
z: Number.isFinite(loc.z) ? Math.trunc(loc.z) : null,
|
||||||
|
region: clip(loc.region, MAX_REGION),
|
||||||
|
house: clip(loc.house, MAX_SHOP),
|
||||||
|
// What the SHOP holds, which is not what the frame carries when it was
|
||||||
|
// truncated. Kept apart so the page can say "showing 250 of 3,104" rather
|
||||||
|
// than presenting a partial shop as a complete one.
|
||||||
|
itemTotal: int(ev.total, int(ev.count, 0)),
|
||||||
|
truncated: ev.truncated === true,
|
||||||
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve each listing's display name.
|
||||||
|
*
|
||||||
|
* Order of preference is the item's own literal `name` first, then the cliloc.
|
||||||
|
* That is the opposite of what "resolve the id" suggests and it is right: a
|
||||||
|
* literal name only exists because a player set one ("Bob's vanquishing kryss"),
|
||||||
|
* and it is strictly more specific than the generic cliloc the item still
|
||||||
|
* carries.
|
||||||
|
*
|
||||||
|
* One batched lookup per frame rather than per item; `resolveMany` is cached and
|
||||||
|
* never throws, so a cliloc table that is missing entirely just leaves
|
||||||
|
* `displayName` null and the page renders item ids, exactly as it did before the
|
||||||
|
* table existed.
|
||||||
|
*/
|
||||||
|
async function shapeItems(ev) {
|
||||||
|
const raw = Array.isArray(ev.items) ? ev.items.slice(0, MAX_ITEMS_PER_VENDOR) : []
|
||||||
|
|
||||||
|
const wanted = raw
|
||||||
|
.map((i) => int(i && i.cliloc, 0))
|
||||||
|
.filter((n) => n > 0)
|
||||||
|
|
||||||
|
const names = await clilocs.resolveMany(wanted)
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.filter((i) => i && i.serial)
|
||||||
|
.map((i) => {
|
||||||
|
const literal = clip(i.name, MAX_NAME)
|
||||||
|
const cliloc = int(i.cliloc, 0) || null
|
||||||
|
return {
|
||||||
|
serial: clip(i.serial, MAX_SERIAL),
|
||||||
|
itemId: int(i.itemId, 0),
|
||||||
|
hue: int(i.hue, 0),
|
||||||
|
amount: int(i.amount, 1),
|
||||||
|
price: int(i.price, 0),
|
||||||
|
name: literal,
|
||||||
|
cliloc,
|
||||||
|
displayName: literal || (cliloc ? clip(names.get(cliloc) ?? null, MAX_NAME) : null),
|
||||||
|
child: i.child === true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Unpriced rows are inventory, not listings. The shard already drops them;
|
||||||
|
// this is the same rule enforced where the table is written, so a plugin that
|
||||||
|
// stops enforcing it cannot put un-buyable rows on the market page.
|
||||||
|
.filter((i) => i.price > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ingest one `vendor.listing` frame. */
|
||||||
|
async function upsertVendor(ev) {
|
||||||
|
if (!ev || !ev.serial) return
|
||||||
|
const vendor = flattenFrame(ev)
|
||||||
|
const items = await shapeItems(ev)
|
||||||
|
await db.replaceVendor(vendor, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ingest one `vendor.listing.remove` frame. */
|
||||||
|
async function removeVendor(serial) {
|
||||||
|
if (!serial) return
|
||||||
|
await db.removeVendor(String(serial).slice(0, MAX_SERIAL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read models ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `location` is re-nested (see flattenFrame) so the stored read model and the
|
||||||
|
// live wire frame present the same keys to the visibility projection.
|
||||||
|
|
||||||
|
const place = (r) => ({
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
region: r.region,
|
||||||
|
house: r.house,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A listing as the search returns it: the item, plus enough of its shop to be
|
||||||
|
// actionable without a second request. `displayName` falls back to nothing rather
|
||||||
|
// than to a fabricated "Item 3922" — the client decides how to render an
|
||||||
|
// unresolved id, and inventing a name here would make it indistinguishable from
|
||||||
|
// a real one.
|
||||||
|
const shapeListing = (r) => ({
|
||||||
|
serial: r.serial,
|
||||||
|
itemId: r.item_id,
|
||||||
|
hue: r.hue,
|
||||||
|
amount: r.amount,
|
||||||
|
price: Number(r.price),
|
||||||
|
name: r.name,
|
||||||
|
cliloc: r.cliloc,
|
||||||
|
displayName: r.display_name,
|
||||||
|
child: !!r.child,
|
||||||
|
vendor: {
|
||||||
|
serial: r.vendor_serial,
|
||||||
|
shopName: r.shop_name,
|
||||||
|
ownerSerial: r.owner_serial,
|
||||||
|
ownerName: r.owner_name,
|
||||||
|
location: place(r),
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapeVendor = (r) => ({
|
||||||
|
serial: r.serial,
|
||||||
|
shopName: r.shop_name,
|
||||||
|
ownerSerial: r.owner_serial,
|
||||||
|
ownerName: r.owner_name,
|
||||||
|
location: place(r),
|
||||||
|
count: r.item_count,
|
||||||
|
total: r.item_total,
|
||||||
|
truncated: !!r.truncated,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapeItem = (r) => ({
|
||||||
|
serial: r.serial,
|
||||||
|
itemId: r.item_id,
|
||||||
|
hue: r.hue,
|
||||||
|
amount: r.amount,
|
||||||
|
price: Number(r.price),
|
||||||
|
name: r.name,
|
||||||
|
cliloc: r.cliloc,
|
||||||
|
displayName: r.display_name,
|
||||||
|
child: !!r.child,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the index. Returns a page of LISTINGS (not vendors) plus the
|
||||||
|
* unpaginated total and the staleness stamp the page's banner needs.
|
||||||
|
*/
|
||||||
|
async function search({
|
||||||
|
q = '',
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
|
itemId,
|
||||||
|
map = '',
|
||||||
|
region = '',
|
||||||
|
sort = 'price_asc',
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
} = {}) {
|
||||||
|
const { rows, total } = await db.searchListings({
|
||||||
|
q: q.trim(),
|
||||||
|
minPrice: Number.isFinite(minPrice) ? minPrice : undefined,
|
||||||
|
maxPrice: Number.isFinite(maxPrice) ? maxPrice : undefined,
|
||||||
|
itemId: Number.isFinite(itemId) ? itemId : undefined,
|
||||||
|
map: map.trim(),
|
||||||
|
region: region.trim(),
|
||||||
|
sort,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = await db.meta()
|
||||||
|
|
||||||
|
return {
|
||||||
|
listings: rows.map(shapeListing),
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
// Repeated on every search response rather than left to a separate /meta
|
||||||
|
// call: the banner that says how old these prices are must age with the
|
||||||
|
// results it labels, and a client that fetched it once would keep showing a
|
||||||
|
// stamp from before the page it is looking at.
|
||||||
|
staleAt: info.staleAt,
|
||||||
|
vendors: info.vendors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One shop and its listings. `null` when the index has never seen that serial. */
|
||||||
|
async function getVendor(serial, { limit = 250, offset = 0 } = {}) {
|
||||||
|
const row = await db.getVendor(serial)
|
||||||
|
if (!row) return null
|
||||||
|
const items = await db.listVendorItems(serial, { limit, offset })
|
||||||
|
return { ...shapeVendor(row), items: items.map(shapeItem) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index size, staleness, and the facet/region filter options. */
|
||||||
|
async function meta() {
|
||||||
|
const [info, places] = await Promise.all([db.meta(), db.listPlaces()])
|
||||||
|
return { ...info, ...places }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Batch size for the post-import pass. Big enough that a 40k-row table is ~40
|
||||||
|
// round trips, small enough that a single batch is not a long-held connection.
|
||||||
|
const RESOLVE_BATCH = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve every listing's display name against the current cliloc table.
|
||||||
|
*
|
||||||
|
* Called after a cliloc import, and it has to be: the market's diff sweep will
|
||||||
|
* NOT re-send an unchanged shop just because the site learned what its items are
|
||||||
|
* called, so without this an operator who configures clilocs after the first
|
||||||
|
* market sweep sees item ids until every shop happens to change. That is the same
|
||||||
|
* class of staleness the spawn atlas avoids by re-parsing on boot — here the
|
||||||
|
* source of truth for names moved, not the data.
|
||||||
|
*
|
||||||
|
* Never throws. It is a cosmetic backfill on a table that is already serving; a
|
||||||
|
* failure means names stay as they were, which is exactly the pre-import state.
|
||||||
|
*/
|
||||||
|
async function refreshDisplayNames() {
|
||||||
|
let after = 0
|
||||||
|
let scanned = 0
|
||||||
|
let changed = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const rows = await db.listResolvableItems(after, RESOLVE_BATCH)
|
||||||
|
if (rows.length === 0) break
|
||||||
|
|
||||||
|
after = rows[rows.length - 1].id
|
||||||
|
scanned += rows.length
|
||||||
|
|
||||||
|
const names = await clilocs.resolveMany(rows.map((r) => Number(r.cliloc)))
|
||||||
|
|
||||||
|
const pairs = []
|
||||||
|
for (const row of rows) {
|
||||||
|
// The literal name still wins, so a re-resolution never overwrites a
|
||||||
|
// player-set name with the generic cliloc behind it.
|
||||||
|
const next = row.name
|
||||||
|
? clip(row.name, MAX_NAME)
|
||||||
|
: clip(names.get(Number(row.cliloc)) ?? null, MAX_NAME)
|
||||||
|
if (next !== row.display_name) pairs.push([next, row.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
changed += await db.updateDisplayNames(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed > 0) log.info('market display names refreshed', { scanned, changed })
|
||||||
|
return { scanned, changed }
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('market display-name refresh failed', { message: err.message, scanned, changed })
|
||||||
|
return { scanned, changed, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertVendor,
|
||||||
|
removeVendor,
|
||||||
|
search,
|
||||||
|
getVendor,
|
||||||
|
meta,
|
||||||
|
refreshDisplayNames,
|
||||||
|
flattenFrame,
|
||||||
|
shapeItems,
|
||||||
|
shapeListing,
|
||||||
|
shapeVendor,
|
||||||
|
MAX_ITEMS_PER_VENDOR,
|
||||||
|
}
|
||||||
369
server/model/shardState/shardState.db.js
Normal file
369
server/model/shardState/shardState.db.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
const { query } = require('../../core')
|
||||||
|
|
||||||
|
// Shared upsert builder for the shard-state tables. Each is keyed on a single
|
||||||
|
// primary column (`pkCol` = pk); `fields` carries only the columns the model
|
||||||
|
// wants to write, so a partial refresh touches nothing else. `coalesce` keeps
|
||||||
|
// the prior column value when the incoming one is NULL (used by shard_online so a
|
||||||
|
// vitals frame that omits acct/name doesn't blank what mob.login set); otherwise
|
||||||
|
// the incoming value wins (VALUES()).
|
||||||
|
function upsertRow(table, pkCol, pk, fields, { coalesce = false } = {}) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const allCols = [pkCol, ...cols]
|
||||||
|
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = allCols.map(() => '?').join(', ')
|
||||||
|
const rhs = coalesce
|
||||||
|
? (c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`
|
||||||
|
: (c) => `\`${c}\` = VALUES(\`${c}\`)`
|
||||||
|
const updates = cols.map(rhs).join(', ')
|
||||||
|
return query(
|
||||||
|
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
[pk, ...cols.map((c) => fields[c])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Online players ─────────────────────────────────────────────────────────
|
||||||
|
const ONLINE_COLS =
|
||||||
|
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
|
||||||
|
|
||||||
|
// Upsert one online player. `fields` already prepared by the model (only the
|
||||||
|
// columns it wants to write); serial is required and is the primary key.
|
||||||
|
// COALESCE variant: a char.vitals frame that omits acct/name must not blank what
|
||||||
|
// mob.login set, so an incoming NULL keeps the prior column value.
|
||||||
|
const upsertOnline = (serial, fields) =>
|
||||||
|
upsertRow('shard_online', 'serial', serial, fields, { coalesce: true })
|
||||||
|
|
||||||
|
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
|
||||||
|
const clearOnline = () => query('DELETE FROM shard_online')
|
||||||
|
|
||||||
|
async function countOnline() {
|
||||||
|
const rows = await query('SELECT COUNT(*) AS n FROM shard_online')
|
||||||
|
return rows[0] ? Number(rows[0].n) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const listOnline = () =>
|
||||||
|
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// Online players on any of the given game accounts (admin: a user's linked
|
||||||
|
// accounts). Empty list short-circuits so we never emit `IN ()`.
|
||||||
|
const listOnlineByAccounts = (accounts) =>
|
||||||
|
accounts.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: query(
|
||||||
|
`SELECT ${ONLINE_COLS} FROM shard_online
|
||||||
|
WHERE acct IN (${accounts.map(() => '?').join(', ')})
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Staff roles whose online presence is shown on the public Shard page. Players
|
||||||
|
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||||
|
// by virtue of being staff.
|
||||||
|
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
|
||||||
|
|
||||||
|
// Online players whose game account is linked to a STAFF website user. Joined
|
||||||
|
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
|
||||||
|
// effect immediately, regardless of whether the player has re-logged since
|
||||||
|
// linking, then through to users so only staff roles are surfaced publicly.
|
||||||
|
const listOnlineLinked = () => {
|
||||||
|
const cols = ONLINE_COLS.split(', ')
|
||||||
|
.map((c) => `o.${c}`)
|
||||||
|
.join(', ')
|
||||||
|
return query(
|
||||||
|
`SELECT ${cols}
|
||||||
|
FROM shard_online o
|
||||||
|
JOIN shard_account_links l ON l.account = o.acct
|
||||||
|
JOIN users u ON u.id = l.user_id
|
||||||
|
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
|
||||||
|
ORDER BY o.name ASC`,
|
||||||
|
PUBLIC_ONLINE_ROLES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Economy supply series ────────────────────────────────────────────────
|
||||||
|
const insertEconomy = ({ accounts, gold, t }) =>
|
||||||
|
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
||||||
|
accounts ?? null,
|
||||||
|
gold ?? null,
|
||||||
|
t,
|
||||||
|
])
|
||||||
|
|
||||||
|
const listEconomy = (limit) =>
|
||||||
|
query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit])
|
||||||
|
|
||||||
|
async function latestEconomy() {
|
||||||
|
const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1')
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Houses / IDOC ────────────────────────────────────────────────────────
|
||||||
|
const HOUSE_COLS =
|
||||||
|
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
|
||||||
|
|
||||||
|
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
|
||||||
|
|
||||||
|
const listIdocHouses = () =>
|
||||||
|
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||||
|
|
||||||
|
// Houses owned by any of the given game accounts (admin: a user's linked
|
||||||
|
// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits.
|
||||||
|
const listHousesByAccounts = (accounts) =>
|
||||||
|
accounts.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: query(
|
||||||
|
`SELECT ${HOUSE_REG_COLS} FROM shard_houses
|
||||||
|
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
|
||||||
|
ORDER BY is_idoc DESC, updated_at DESC`,
|
||||||
|
accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||||
|
// The registry columns extend HOUSE_COLS; a registry row is one we've seen via
|
||||||
|
// house.update (in_registry = 1), as opposed to a decay-only transition row.
|
||||||
|
const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry`
|
||||||
|
|
||||||
|
const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial])
|
||||||
|
|
||||||
|
// The full registered-house browser: every row we've seen via house.update.
|
||||||
|
const listRegistryHouses = () =>
|
||||||
|
query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||||
|
const CHAMP_COLS =
|
||||||
|
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
|
||||||
|
|
||||||
|
const upsertChamp = (serial, fields) => upsertRow('shard_champs', 'serial', serial, fields)
|
||||||
|
|
||||||
|
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
|
||||||
|
const clearChamps = () => query('DELETE FROM shard_champs')
|
||||||
|
// Ordered by name (matches the sidecar's /champs ordering).
|
||||||
|
const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||||
|
const PAGE_COLS =
|
||||||
|
'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
|
||||||
|
|
||||||
|
async function upsertPage(pageId, fields) {
|
||||||
|
const cols = Object.keys(fields)
|
||||||
|
const allCols = ['page_id', ...cols]
|
||||||
|
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = allCols.map(() => '?').join(', ')
|
||||||
|
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_pages (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
[pageId, ...cols.map((c) => fields[c])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
|
||||||
|
const clearPages = () => query('DELETE FROM shard_pages')
|
||||||
|
// Oldest-open first so the queue reads like a work list.
|
||||||
|
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
|
||||||
|
|
||||||
|
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||||
|
const GUILD_COLS =
|
||||||
|
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
|
||||||
|
|
||||||
|
const upsertGuild = (id, fields) => upsertRow('shard_guilds', 'id', id, fields)
|
||||||
|
|
||||||
|
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
|
||||||
|
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
||||||
|
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||||
|
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||||
|
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||||
|
const findGuildLedByActor = (serial, acct) =>
|
||||||
|
query(
|
||||||
|
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||||
|
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
|
||||||
|
LIMIT 1`,
|
||||||
|
[serial ?? null, acct ?? null],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
|
||||||
|
const listGuildsLedByAccounts = (accounts) =>
|
||||||
|
accounts.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: query(
|
||||||
|
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||||
|
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
|
||||||
|
const GOV_COLS =
|
||||||
|
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
|
||||||
|
|
||||||
|
const upsertGovernor = (city, fields) => upsertRow('shard_governors', 'city', city, fields)
|
||||||
|
|
||||||
|
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)
|
||||||
|
|
||||||
|
// Cities whose current governor is one of the given game accounts (cross-link:
|
||||||
|
// does this user hold a governorship?). Empty list short-circuits.
|
||||||
|
const listGovernorshipsByAccounts = (accounts) =>
|
||||||
|
accounts.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: query(
|
||||||
|
`SELECT ${GOV_COLS} FROM shard_governors
|
||||||
|
WHERE governor_acct IN (${accounts.map(() => '?').join(', ')})
|
||||||
|
ORDER BY city ASC`,
|
||||||
|
accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The single open term (ended_at IS NULL) for a city, if any.
|
||||||
|
async function currentGovernorTerm(city) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1',
|
||||||
|
[city],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeGovernorTerm = (id, endedAt) =>
|
||||||
|
query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id])
|
||||||
|
|
||||||
|
const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) =>
|
||||||
|
query(
|
||||||
|
`INSERT INTO shard_governor_terms
|
||||||
|
(city, governor_serial, governor_name, governor_acct, governor_web_id, started_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt],
|
||||||
|
)
|
||||||
|
|
||||||
|
const listGovernorTerms = (city, limit) =>
|
||||||
|
query(
|
||||||
|
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?',
|
||||||
|
[city, limit],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||||
|
async function setPresence({ count, byFacet, byRegion, t }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet),
|
||||||
|
by_region = VALUES(by_region), t = VALUES(t)`,
|
||||||
|
[
|
||||||
|
Number.isFinite(count) ? count : 0,
|
||||||
|
byFacet ? JSON.stringify(byFacet) : null,
|
||||||
|
byRegion ? JSON.stringify(byRegion) : null,
|
||||||
|
Number.isFinite(t) ? t : null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestPresence() {
|
||||||
|
const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1')
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
clearOnline,
|
||||||
|
countOnline,
|
||||||
|
listOnline,
|
||||||
|
listOnlineLinked,
|
||||||
|
listOnlineByAccounts,
|
||||||
|
insertEconomy,
|
||||||
|
listEconomy,
|
||||||
|
latestEconomy,
|
||||||
|
upsertHouse,
|
||||||
|
listIdocHouses,
|
||||||
|
listHousesByAccounts,
|
||||||
|
removeHouse,
|
||||||
|
listRegistryHouses,
|
||||||
|
upsertGuild,
|
||||||
|
removeGuild,
|
||||||
|
clearGuilds,
|
||||||
|
listGuilds,
|
||||||
|
findGuildLedByActor,
|
||||||
|
listGuildsLedByAccounts,
|
||||||
|
upsertGovernor,
|
||||||
|
listGovernors,
|
||||||
|
listGovernorshipsByAccounts,
|
||||||
|
currentGovernorTerm,
|
||||||
|
closeGovernorTerm,
|
||||||
|
openGovernorTerm,
|
||||||
|
listGovernorTerms,
|
||||||
|
setPresence,
|
||||||
|
latestPresence,
|
||||||
|
setRuleset,
|
||||||
|
getRuleset,
|
||||||
|
upsertPointsBoard,
|
||||||
|
listPointsBoards,
|
||||||
|
getPointsBoard,
|
||||||
|
upsertChamp,
|
||||||
|
removeChamp,
|
||||||
|
clearChamps,
|
||||||
|
listChamps,
|
||||||
|
upsertPage,
|
||||||
|
removePage,
|
||||||
|
clearPages,
|
||||||
|
listPages,
|
||||||
|
}
|
||||||
638
server/model/shardState/shardState.model.js
Normal file
638
server/model/shardState/shardState.model.js
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
// Live shard state derived from the WS feed: who is online, the gold-supply
|
||||||
|
// series, and per-house decay stage. The ingest dispatcher calls the write
|
||||||
|
// methods; the public read endpoints call the list/count methods. Writes take
|
||||||
|
// camelCase semantic objects and map to the snake_case columns; only the keys
|
||||||
|
// present are written (so a char.vitals refresh doesn't clobber login fields).
|
||||||
|
|
||||||
|
const db = require('./shardState.db')
|
||||||
|
|
||||||
|
const MAX_ECONOMY = 1000
|
||||||
|
|
||||||
|
// Small coercion helpers, kept out of the upsert builders below so those stay
|
||||||
|
// flat (each inline `?? null` / ternary otherwise adds to cognitive complexity).
|
||||||
|
const orNull = (v) => v ?? null
|
||||||
|
const toDate = (v) => (v ? new Date(v) : null)
|
||||||
|
// Owner is an actor object (or null for an abandoned house); flatten to columns.
|
||||||
|
const ownerFields = (owner) => ({
|
||||||
|
owner_serial: orNull(owner?.serial),
|
||||||
|
owner_acct: orNull(owner?.acct),
|
||||||
|
owner_name: orNull(owner?.name),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
||||||
|
// partial refresh only touches the fields it carries.
|
||||||
|
function onlineFields(data) {
|
||||||
|
const map = {
|
||||||
|
name: data.name,
|
||||||
|
acct: data.acct,
|
||||||
|
web_id: data.webId,
|
||||||
|
map: data.map,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
z: data.z,
|
||||||
|
hits: data.hits,
|
||||||
|
hits_max: data.hitsMax,
|
||||||
|
mana: data.mana,
|
||||||
|
mana_max: data.manaMax,
|
||||||
|
stam: data.stam,
|
||||||
|
stam_max: data.stamMax,
|
||||||
|
str: data.str,
|
||||||
|
dex: data.dex,
|
||||||
|
int: data.int,
|
||||||
|
}
|
||||||
|
const fields = {}
|
||||||
|
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
|
||||||
|
async function upsertOnline(data) {
|
||||||
|
if (!data || !data.serial) return
|
||||||
|
await db.upsertOnline(data.serial, onlineFields(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setOffline = (serial) => db.removeOnline(serial)
|
||||||
|
const clearOnline = () => db.clearOnline()
|
||||||
|
const onlineCount = () => db.countOnline()
|
||||||
|
|
||||||
|
function shapeOnline(r) {
|
||||||
|
return {
|
||||||
|
serial: r.serial,
|
||||||
|
name: r.name,
|
||||||
|
acct: r.acct,
|
||||||
|
webId: r.web_id,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
hits: r.hits,
|
||||||
|
hitsMax: r.hits_max,
|
||||||
|
mana: r.mana,
|
||||||
|
manaMax: r.mana_max,
|
||||||
|
stam: r.stam,
|
||||||
|
stamMax: r.stam_max,
|
||||||
|
str: r.str,
|
||||||
|
dex: r.dex,
|
||||||
|
int: r.int,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only players whose account is linked to a website user (opt-in visibility).
|
||||||
|
async function listOnlineLinked() {
|
||||||
|
const rows = await db.listOnlineLinked()
|
||||||
|
return rows.map(shapeOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listOnline() {
|
||||||
|
const rows = await db.listOnline()
|
||||||
|
return rows.map(shapeOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a gold-supply sample (economy.supply).
|
||||||
|
async function addEconomySample({ accounts, gold, t }) {
|
||||||
|
await db.insertEconomy({ accounts, gold, t })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listEconomy(limit = 100) {
|
||||||
|
const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
|
||||||
|
const rows = await db.listEconomy(n)
|
||||||
|
// Return oldest → newest for charting.
|
||||||
|
return rows
|
||||||
|
.map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
|
||||||
|
.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestEconomy() {
|
||||||
|
const r = await db.latestEconomy()
|
||||||
|
return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
|
||||||
|
async function upsertHouse(data) {
|
||||||
|
if (!data || !data.serial) return
|
||||||
|
const fields = {
|
||||||
|
stage: data.stage ?? null,
|
||||||
|
map: data.map ?? null,
|
||||||
|
x: data.x ?? null,
|
||||||
|
y: data.y ?? null,
|
||||||
|
z: data.z ?? null,
|
||||||
|
region: data.region ?? null,
|
||||||
|
name: data.name ?? null,
|
||||||
|
owner_serial: data.ownerSerial ?? null,
|
||||||
|
owner_acct: data.ownerAcct ?? null,
|
||||||
|
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||||
|
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||||
|
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
||||||
|
}
|
||||||
|
await db.upsertHouse(data.serial, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeHouse(r) {
|
||||||
|
return {
|
||||||
|
serial: r.serial,
|
||||||
|
stage: r.stage,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
region: r.region,
|
||||||
|
name: r.name,
|
||||||
|
ownerSerial: r.owner_serial,
|
||||||
|
ownerAcct: r.owner_acct,
|
||||||
|
// Registry fields (Protocol 2.0 house.update); undefined on decay-only rows.
|
||||||
|
ownerName: r.owner_name,
|
||||||
|
coOwners: r.co_owners,
|
||||||
|
friends: r.friends,
|
||||||
|
price: r.price == null ? null : Number(r.price),
|
||||||
|
decay: r.decay,
|
||||||
|
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
|
||||||
|
builtOn: r.built_on,
|
||||||
|
lastRefreshed: r.last_refreshed,
|
||||||
|
isIdoc: Boolean(r.is_idoc),
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listIdoc() {
|
||||||
|
const rows = await db.listIdocHouses()
|
||||||
|
return rows.map(shapeHouse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Houses owned by the given game accounts (admin: a user's linked accounts).
|
||||||
|
async function listHousesForAccounts(accounts) {
|
||||||
|
const rows = await db.listHousesByAccounts(accounts)
|
||||||
|
return rows.map(shapeHouse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||||
|
// Richer per-house snapshot than the decay-transition feed. Writes only the
|
||||||
|
// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by
|
||||||
|
// the house.decay path, so the two feeds never clobber each other. owner is an
|
||||||
|
// actor object (or null for an abandoned house).
|
||||||
|
async function upsertHouseRegistry(data) {
|
||||||
|
if (!data || !data.serial) return
|
||||||
|
const fields = {
|
||||||
|
name: orNull(data.name),
|
||||||
|
...ownerFields(data.owner || null),
|
||||||
|
co_owners: orNull(data.coOwners),
|
||||||
|
friends: orNull(data.friends),
|
||||||
|
price: orNull(data.price),
|
||||||
|
decay: orNull(data.decay),
|
||||||
|
region: orNull(data.region),
|
||||||
|
map: orNull(data.map),
|
||||||
|
x: orNull(data.x),
|
||||||
|
y: orNull(data.y),
|
||||||
|
z: orNull(data.z),
|
||||||
|
built_on: toDate(data.builtOn),
|
||||||
|
last_refreshed: toDate(data.lastRefreshed),
|
||||||
|
in_registry: 1,
|
||||||
|
}
|
||||||
|
await db.upsertHouse(data.serial, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve())
|
||||||
|
|
||||||
|
async function listHouses() {
|
||||||
|
const rows = await db.listRegistryHouses()
|
||||||
|
return rows.map(shapeHouse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online players on the given game accounts (admin: a user's linked accounts).
|
||||||
|
async function listOnlineForAccounts(accounts) {
|
||||||
|
const rows = await db.listOnlineByAccounts(accounts)
|
||||||
|
return rows.map(shapeOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||||
|
// Upsert a champ spawn's state (champ.update). The full event is stored in
|
||||||
|
// `payload` for the category-specific fields; a few columns are hoisted out for
|
||||||
|
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
|
||||||
|
async function upsertChamp(ev) {
|
||||||
|
if (!ev || !ev.serial) return
|
||||||
|
await db.upsertChamp(ev.serial, {
|
||||||
|
category: orNull(ev.category),
|
||||||
|
type: orNull(ev.type),
|
||||||
|
name: orNull(ev.name),
|
||||||
|
status: orNull(ev.status),
|
||||||
|
active: ev.active ? 1 : 0,
|
||||||
|
map: orNull(ev.map),
|
||||||
|
x: orNull(ev.x),
|
||||||
|
y: orNull(ev.y),
|
||||||
|
z: orNull(ev.z),
|
||||||
|
boss_up: ev.bossUp ? 1 : 0,
|
||||||
|
payload: JSON.stringify(ev),
|
||||||
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
|
||||||
|
const clearChamps = () => db.clearChamps()
|
||||||
|
|
||||||
|
// Return the stored champ.update payload (the shape the sidecar/UI expect),
|
||||||
|
// falling back to the hoisted columns if an older row lacks a payload.
|
||||||
|
function shapeChamp(r) {
|
||||||
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
|
return payload || {
|
||||||
|
kind: 'champ.update',
|
||||||
|
serial: r.serial,
|
||||||
|
category: r.category,
|
||||||
|
type: r.type,
|
||||||
|
name: r.name,
|
||||||
|
status: r.status,
|
||||||
|
active: Boolean(r.active),
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
bossUp: Boolean(r.boss_up),
|
||||||
|
t: r.t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listChamps() {
|
||||||
|
const rows = await db.listChamps()
|
||||||
|
return rows.map(shapeChamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
|
||||||
|
async function replaceChamps(spawns) {
|
||||||
|
await db.clearChamps()
|
||||||
|
for (const ev of spawns || []) await upsertChamp(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||||
|
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
|
||||||
|
// name/acct/webId; the rest are top-level fields.
|
||||||
|
async function upsertPage(ev) {
|
||||||
|
const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
|
||||||
|
if (!pageId) return
|
||||||
|
const sender = ev.sender || {}
|
||||||
|
await db.upsertPage(pageId, {
|
||||||
|
type: orNull(ev.type),
|
||||||
|
sender_name: orNull(sender.name),
|
||||||
|
sender_acct: orNull(sender.acct),
|
||||||
|
web_id: orNull(sender.webId),
|
||||||
|
message: orNull(ev.message),
|
||||||
|
map: orNull(ev.map),
|
||||||
|
x: orNull(ev.x),
|
||||||
|
y: orNull(ev.y),
|
||||||
|
z: orNull(ev.z),
|
||||||
|
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
|
||||||
|
handled: ev.handled ? 1 : 0,
|
||||||
|
handler: orNull(ev.handler),
|
||||||
|
payload: JSON.stringify(ev),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
|
||||||
|
const clearPages = () => db.clearPages()
|
||||||
|
|
||||||
|
function shapePage(r) {
|
||||||
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
|
return {
|
||||||
|
pageId: r.page_id,
|
||||||
|
type: r.type,
|
||||||
|
sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
|
||||||
|
message: r.message,
|
||||||
|
map: r.map,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
|
||||||
|
handled: Boolean(r.handled),
|
||||||
|
handler: r.handler,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
// Keep the raw payload available for any field not hoisted above.
|
||||||
|
payload: payload || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPages() {
|
||||||
|
const rows = await db.listPages()
|
||||||
|
return rows.map(shapePage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
|
||||||
|
async function replacePages(pages) {
|
||||||
|
await db.clearPages()
|
||||||
|
for (const ev of pages || []) await upsertPage(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||||
|
// Upsert a guild's roster snapshot (guild.update). The leader is an actor object
|
||||||
|
// flattened into leader_* columns; the full event lives in `payload`.
|
||||||
|
async function upsertGuild(ev) {
|
||||||
|
if (!ev || ev.id == null) return
|
||||||
|
const leader = ev.leader || {}
|
||||||
|
await db.upsertGuild(ev.id, {
|
||||||
|
name: ev.name ?? null,
|
||||||
|
abbr: ev.abbr ?? null,
|
||||||
|
members: ev.members ?? null,
|
||||||
|
online: ev.online ?? null,
|
||||||
|
alliance: ev.alliance ?? null,
|
||||||
|
leader_serial: leader.serial ?? null,
|
||||||
|
leader_name: leader.name ?? null,
|
||||||
|
leader_acct: leader.acct ?? null,
|
||||||
|
leader_web_id: leader.webId ?? null,
|
||||||
|
payload: JSON.stringify(ev),
|
||||||
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
||||||
|
const clearGuilds = () => db.clearGuilds()
|
||||||
|
|
||||||
|
function shapeGuild(r) {
|
||||||
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
|
return payload || {
|
||||||
|
kind: 'guild.update',
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
abbr: r.abbr,
|
||||||
|
members: r.members,
|
||||||
|
online: r.online,
|
||||||
|
alliance: r.alliance,
|
||||||
|
leader: r.leader_serial
|
||||||
|
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
|
||||||
|
: null,
|
||||||
|
t: r.t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGuilds() {
|
||||||
|
const rows = await db.listGuilds()
|
||||||
|
return rows.map(shapeGuild)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
|
||||||
|
async function replaceGuilds(guilds) {
|
||||||
|
await db.clearGuilds()
|
||||||
|
for (const ev of guilds || []) await upsertGuild(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
||||||
|
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
||||||
|
// null rather than show a possibly-stale guess.
|
||||||
|
async function findGuildForActor({ serial, acct }) {
|
||||||
|
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
||||||
|
const g = rows[0]
|
||||||
|
if (!g) return null
|
||||||
|
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
||||||
|
async function listGuildsLedForAccounts(accounts) {
|
||||||
|
const rows = await db.listGuildsLedByAccounts(accounts)
|
||||||
|
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
||||||
|
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
||||||
|
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
||||||
|
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
|
||||||
|
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
|
||||||
|
async function upsertGovernor(ev) {
|
||||||
|
if (!ev || !ev.city) return
|
||||||
|
await recordGovernorTransition(ev)
|
||||||
|
const gov = ev.governor
|
||||||
|
const elect = ev.governorElect
|
||||||
|
await db.upsertGovernor(ev.city, {
|
||||||
|
governor_serial: orNull(gov?.serial),
|
||||||
|
governor_name: orNull(gov?.name),
|
||||||
|
governor_acct: orNull(gov?.acct),
|
||||||
|
governor_web_id: orNull(gov?.webId),
|
||||||
|
elect_serial: orNull(elect?.serial),
|
||||||
|
elect_name: orNull(elect?.name),
|
||||||
|
elect_acct: orNull(elect?.acct),
|
||||||
|
election_phase: orNull(ev.electionPhase),
|
||||||
|
candidates: orNull(ev.candidates),
|
||||||
|
auto_pick_at: toDate(ev.autoPickAt),
|
||||||
|
payload: JSON.stringify(ev),
|
||||||
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the open term and open a new one when the governor CHANGES. Idempotent:
|
||||||
|
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
|
||||||
|
// city.update events never spawn spurious terms).
|
||||||
|
async function recordGovernorTransition(ev) {
|
||||||
|
const gov = ev.governor || null
|
||||||
|
const newSerial = gov ? gov.serial ?? null : null
|
||||||
|
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
|
||||||
|
const open = await db.currentGovernorTerm(ev.city)
|
||||||
|
const openSerial = open ? open.governor_serial : null
|
||||||
|
if (open && openSerial === newSerial) return // unchanged — nothing to record
|
||||||
|
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
|
||||||
|
if (newSerial) {
|
||||||
|
await db.openGovernorTerm({
|
||||||
|
city: ev.city,
|
||||||
|
serial: newSerial,
|
||||||
|
name: gov.name ?? null,
|
||||||
|
acct: gov.acct ?? null,
|
||||||
|
webId: gov.webId ?? null,
|
||||||
|
startedAt: t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeGovernor(r) {
|
||||||
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
|
return payload || {
|
||||||
|
kind: 'city.update',
|
||||||
|
city: r.city,
|
||||||
|
governor: r.governor_serial
|
||||||
|
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||||
|
: null,
|
||||||
|
governorElect: r.elect_serial
|
||||||
|
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
|
||||||
|
: null,
|
||||||
|
electionPhase: r.election_phase,
|
||||||
|
candidates: r.candidates,
|
||||||
|
t: r.t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGovernors() {
|
||||||
|
const rows = await db.listGovernors()
|
||||||
|
return rows.map(shapeGovernor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cities the given game accounts currently govern (cross-link badge).
|
||||||
|
async function listGovernorshipsForAccounts(accounts) {
|
||||||
|
const rows = await db.listGovernorshipsByAccounts(accounts)
|
||||||
|
return rows.map(shapeGovernor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Term history for a city (look-back), newest first.
|
||||||
|
async function listGovernorHistory(city, limit = 100) {
|
||||||
|
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||||
|
const rows = await db.listGovernorTerms(city, n)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
city: r.city,
|
||||||
|
governor: r.governor_serial
|
||||||
|
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||||
|
: null,
|
||||||
|
startedAt: r.started_at == null ? null : Number(r.started_at),
|
||||||
|
endedAt: r.ended_at == null ? null : Number(r.ended_at),
|
||||||
|
votes: r.votes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert governors without clearing (cities are fixed, no remove event); term
|
||||||
|
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
|
||||||
|
async function replaceGovernors(cities) {
|
||||||
|
for (const ev of cities || []) await upsertGovernor(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||||
|
async function setPresence(ev) {
|
||||||
|
if (!ev) return
|
||||||
|
await db.setPresence({
|
||||||
|
count: ev.count,
|
||||||
|
byFacet: ev.byFacet || null,
|
||||||
|
byRegion: ev.byRegion || null,
|
||||||
|
t: ev.t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestPresence() {
|
||||||
|
const r = await db.latestPresence()
|
||||||
|
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
|
||||||
|
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
|
||||||
|
return {
|
||||||
|
count: Number(r.count) || 0,
|
||||||
|
byFacet: parse(r.by_facet),
|
||||||
|
byRegion: parse(r.by_region),
|
||||||
|
t: r.t == null ? null : Number(r.t),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
upsertOnline,
|
||||||
|
setOffline,
|
||||||
|
clearOnline,
|
||||||
|
onlineCount,
|
||||||
|
listOnline,
|
||||||
|
listOnlineLinked,
|
||||||
|
listOnlineForAccounts,
|
||||||
|
addEconomySample,
|
||||||
|
listEconomy,
|
||||||
|
latestEconomy,
|
||||||
|
upsertHouse,
|
||||||
|
listIdoc,
|
||||||
|
listHousesForAccounts,
|
||||||
|
upsertHouseRegistry,
|
||||||
|
removeHouse,
|
||||||
|
listHouses,
|
||||||
|
upsertChamp,
|
||||||
|
removeChamp,
|
||||||
|
clearChamps,
|
||||||
|
listChamps,
|
||||||
|
replaceChamps,
|
||||||
|
upsertPage,
|
||||||
|
removePage,
|
||||||
|
clearPages,
|
||||||
|
listPages,
|
||||||
|
replacePages,
|
||||||
|
upsertGuild,
|
||||||
|
removeGuild,
|
||||||
|
clearGuilds,
|
||||||
|
listGuilds,
|
||||||
|
replaceGuilds,
|
||||||
|
findGuildForActor,
|
||||||
|
listGuildsLedForAccounts,
|
||||||
|
upsertGovernor,
|
||||||
|
listGovernors,
|
||||||
|
listGovernorshipsForAccounts,
|
||||||
|
listGovernorHistory,
|
||||||
|
replaceGovernors,
|
||||||
|
setPresence,
|
||||||
|
latestPresence,
|
||||||
|
setRuleset,
|
||||||
|
getRuleset,
|
||||||
|
upsertPointsBoard,
|
||||||
|
listPointsBoards,
|
||||||
|
getPointsBoard,
|
||||||
|
}
|
||||||
37
server/model/shardVisibility/shardVisibility.db.js
Normal file
37
server/model/shardVisibility/shardVisibility.db.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const { query } = require('../../core')
|
||||||
|
|
||||||
|
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||||
|
// compiles a default for every known feature and merges stored rows over it, so
|
||||||
|
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||||
|
|
||||||
|
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||||
|
|
||||||
|
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||||
|
|
||||||
|
const getOne = (feature) =>
|
||||||
|
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||||
|
|
||||||
|
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||||
|
// {field: rung}; the caller has already stripped locked fields and validated
|
||||||
|
// every rung against the ladder.
|
||||||
|
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||||
|
query(
|
||||||
|
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
enabled = VALUES(enabled),
|
||||||
|
audience = VALUES(audience),
|
||||||
|
stream = VALUES(stream),
|
||||||
|
field_rules = VALUES(field_rules),
|
||||||
|
updated_by = VALUES(updated_by)`,
|
||||||
|
[
|
||||||
|
feature,
|
||||||
|
enabled ? 1 : 0,
|
||||||
|
audience,
|
||||||
|
stream ? 1 : 0,
|
||||||
|
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||||
|
updatedBy ?? null,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = { listAll, getOne, upsert }
|
||||||
44
server/model/shardVisibility/shardVisibility.model.js
Normal file
44
server/model/shardVisibility/shardVisibility.model.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||||
|
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||||
|
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||||
|
|
||||||
|
const db = require('./shardVisibility.db')
|
||||||
|
|
||||||
|
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||||
|
function parseRules(raw) {
|
||||||
|
if (raw == null) return {}
|
||||||
|
if (typeof raw === 'object') return raw
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSafe = (row) =>
|
||||||
|
row && {
|
||||||
|
feature: row.feature,
|
||||||
|
enabled: !!row.enabled,
|
||||||
|
audience: row.audience,
|
||||||
|
stream: row.stream == null ? null : !!row.stream,
|
||||||
|
fieldRules: parseRules(row.field_rules),
|
||||||
|
updatedBy: row.updated_by,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAll() {
|
||||||
|
const rows = await db.listAll()
|
||||||
|
return rows.map(toSafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOne(feature) {
|
||||||
|
const rows = await db.getOne(feature)
|
||||||
|
return toSafe(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsert = (entry) => db.upsert(entry)
|
||||||
|
|
||||||
|
module.exports = { listAll, getOne, upsert }
|
||||||
35
server/model/singletonConfigDb.js
Normal file
35
server/model/singletonConfigDb.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
const { query } = require('../core')
|
||||||
|
|
||||||
|
// Factory for the singleton config tables (bot_config, email_config,
|
||||||
|
// uo_link_config). Each is a one-row table keyed on id = 1: `get()` returns the
|
||||||
|
// row (or null before the admin first saves it), and `upsert()` writes only the
|
||||||
|
// columns the model prepared, leaving the rest untouched. The three tables share
|
||||||
|
// this shape exactly, so the DB layer is generated rather than copy-pasted —
|
||||||
|
// only the table name and column list differ.
|
||||||
|
//
|
||||||
|
// `fields` are already prepared by the model (secrets pre-encrypted); ordering
|
||||||
|
// and encryption stay a model-layer concern.
|
||||||
|
function singletonConfigDb(table, cols) {
|
||||||
|
async function get() {
|
||||||
|
const rows = await query(`SELECT ${cols} FROM ${table} WHERE id = 1 LIMIT 1`)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsert(fields) {
|
||||||
|
const columns = Object.keys(fields)
|
||||||
|
const vals = columns.map((c) => fields[c])
|
||||||
|
const insertCols = ['id', ...columns].map((c) => `\`${c}\``).join(', ')
|
||||||
|
const placeholders = ['1', ...columns.map(() => '?')].join(', ')
|
||||||
|
const updates = columns.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||||
|
await query(
|
||||||
|
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
|
||||||
|
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
return get()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, upsert }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = singletonConfigDb
|
||||||
7
server/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
7
server/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const singletonConfigDb = require('../singletonConfigDb')
|
||||||
|
|
||||||
|
const COLS =
|
||||||
|
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
|
||||||
|
|
||||||
|
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
|
||||||
|
module.exports = singletonConfigDb('uo_link_config', COLS)
|
||||||
88
server/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
88
server/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB
|
||||||
|
// layer only ever sees ciphertext, and only getWithToken() (used server-side to
|
||||||
|
// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never
|
||||||
|
// includes the token — it exposes only `hasToken`. A blank `token` on save means
|
||||||
|
// "leave the existing token unchanged" (same convention as the other configs).
|
||||||
|
|
||||||
|
const db = require('./uoLinkConfig.db')
|
||||||
|
const { secretBox } = require('../../core')
|
||||||
|
|
||||||
|
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
|
||||||
|
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||||
|
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||||
|
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
|
||||||
|
|
||||||
|
function toSafe(row) {
|
||||||
|
if (!row) {
|
||||||
|
return {
|
||||||
|
baseUrl: process.env.UOLINK_BASE_URL || null,
|
||||||
|
wsUrl: process.env.UOLINK_WS_URL || null,
|
||||||
|
protocol: DEFAULT_PROTOCOL,
|
||||||
|
enabled: false,
|
||||||
|
hasToken: false,
|
||||||
|
status: 'disconnected',
|
||||||
|
statusDetail: null,
|
||||||
|
pluginConnected: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
bootId: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: row.base_url || null,
|
||||||
|
wsUrl: row.ws_url || null,
|
||||||
|
protocol: row.protocol || DEFAULT_PROTOCOL,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
hasToken: Boolean(row.auth_token_enc),
|
||||||
|
status: row.status || 'disconnected',
|
||||||
|
statusDetail: row.status_detail || null,
|
||||||
|
pluginConnected: Boolean(row.plugin_connected),
|
||||||
|
lastEventAt: row.last_event_at || null,
|
||||||
|
bootId: row.boot_id || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSafe() {
|
||||||
|
return toSafe(await db.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypted token included — server-side only (calling the sidecar's REST/WS
|
||||||
|
// API). Returns null when nothing has been saved yet.
|
||||||
|
async function getWithToken() {
|
||||||
|
const row = await db.get()
|
||||||
|
if (!row) return null
|
||||||
|
return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save admin-supplied config. `token` undefined or '' means "leave the existing
|
||||||
|
// token unchanged" (same convention as botConfig.save).
|
||||||
|
async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) {
|
||||||
|
const fields = {}
|
||||||
|
if (baseUrl !== undefined) fields.base_url = baseUrl
|
||||||
|
if (wsUrl !== undefined) fields.ws_url = wsUrl
|
||||||
|
if (token) fields.auth_token_enc = secretBox.encrypt(token)
|
||||||
|
if (protocol !== undefined) fields.protocol = protocol
|
||||||
|
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||||
|
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the sidecar's last-reported connection state into the DB so the admin
|
||||||
|
// panel has something to show between polls and the public status endpoint can
|
||||||
|
// read it without a live round-trip.
|
||||||
|
async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) {
|
||||||
|
const fields = {}
|
||||||
|
if (status !== undefined) fields.status = status
|
||||||
|
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||||
|
if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0
|
||||||
|
// lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the
|
||||||
|
// mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same
|
||||||
|
// fix as botConfig.recordStatus's last_connected_at).
|
||||||
|
if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null
|
||||||
|
if (bootId !== undefined) fields.boot_id = bootId
|
||||||
|
if (Object.keys(fields).length === 0) return getSafe()
|
||||||
|
const row = await db.upsert(fields)
|
||||||
|
return toSafe(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||||
24
server/package-lock.json
generated
24
server/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "module-uo-server",
|
"name": "module-uo-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-validator": "^7.2.0"
|
"express-validator": "^7.2.0"
|
||||||
@@ -927,6 +930,27 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test",
|
"test": "node --test --require ./test/_setup.js",
|
||||||
"check:imports": "node scripts/checkImports.js"
|
"check:imports": "node scripts/checkImports.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"//dependencies": "Deliberately none. Everything the shipped server half needs arrives on ctx (MODULE_API.md §2.3) — a module lives outside core's server/ and cannot resolve core's node_modules. The two below are devDependencies because test/_fakes.js builds a REAL express router: a fake Router would test the fake.",
|
"//dependencies": "The ONE runtime dependency, and it ships inside the release tarball: CI runs npm ci --omit=dev and packs server/node_modules, because an operator never builds (MODULE_SYSTEM.md 1.14). Node resolves it by walking up from modules/uo/server/. Everything else the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger, the middleware and the rate-limit factory are all core-owned and handed over.",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-validator": "^7.2.0"
|
"express-validator": "^7.2.0"
|
||||||
}
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"//devDependencies": "Test-only. test/_fakes.js builds a REAL express router - a fake Router would test the fake."
|
||||||
}
|
}
|
||||||
|
|||||||
387
server/router/admin/shard.router.js
Normal file
387
server/router/admin/shard.router.js
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
// Admin · Shard — everything under /api/v1/admin/shard, in two tiers.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/admin/shard by admin/index.js, which already applied
|
||||||
|
// `noindex, isLoggedIn, staffOnly`. Two capabilities share this prefix, and
|
||||||
|
// prefix ownership is the invariant the split preserves — so they share a file:
|
||||||
|
//
|
||||||
|
// 1. Self-service game-account linking (no extra gate). A staff member links
|
||||||
|
// and inspects their OWN in-game account exactly as a player does under
|
||||||
|
// /player/shard; the handlers are the very same `player/shard.controller`
|
||||||
|
// ones, keyed off req.user.id. These keep their `Admin · Account` swagger
|
||||||
|
// tag, which is why the tag disagrees with this filename.
|
||||||
|
// 2. Privileged live-shard operations and the help-page queue (`modAccess` —
|
||||||
|
// admin or moderator). `actor` is stamped server-side from the session in
|
||||||
|
// shardOps.controller.js; the request body never carries it.
|
||||||
|
//
|
||||||
|
// `modAccess` stays a per-route gate rather than a router-level `use`: it was
|
||||||
|
// per-route in admin.routes.js, and half the routes here must NOT have it.
|
||||||
|
//
|
||||||
|
// NOTE: /admin/shard/pages is the in-game help-page (support) queue. It is
|
||||||
|
// unrelated to /admin/pages, the CMS page builder.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const { body, param } = core.validator
|
||||||
|
|
||||||
|
const shardOps = require('./shardOps.controller')
|
||||||
|
const shardVisibility = require('./shardVisibility.controller')
|
||||||
|
const shardAtlas = require('./shardAtlas.controller')
|
||||||
|
const shardClilocs = require('./shardClilocs.controller')
|
||||||
|
const selfShard = require('../player/shard.controller')
|
||||||
|
const { requireRole, validate } = core.middleware
|
||||||
|
|
||||||
|
const shardRouter = express.Router()
|
||||||
|
|
||||||
|
// Moderator gate. Admins can do everything a moderator can.
|
||||||
|
const modAccess = requireRole('admin', 'moderator')
|
||||||
|
// Admin-only gate, for settings that decide what the PUBLIC sees.
|
||||||
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
// ── Game account linking (self-service, any staff role) ───────────────
|
||||||
|
// Staff link their OWN in-game account here, exactly like players do under
|
||||||
|
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||||
|
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
shardRouter.post(
|
||||||
|
'/link',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||||
|
validate,
|
||||||
|
selfShard.link,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/accounts',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
selfShard.listAccounts,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/roster/:account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
selfShard.roster,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/vendors/:account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
selfShard.vendors,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/char/:serial',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
selfShard.getChar,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/sales',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
selfShard.getSales,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
|
||||||
|
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||||
|
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||||
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
|
validate,
|
||||||
|
selfShard.createGameAccount,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||||
|
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||||
|
// well as admins (modAccess). `actor` is stamped server-side from the session in
|
||||||
|
// the controller — the body never carries it. See shardOps.controller.js.
|
||||||
|
shardRouter.post(
|
||||||
|
'/kick',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
modAccess,
|
||||||
|
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||||
|
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
shardOps.kick,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/ban',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
modAccess,
|
||||||
|
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||||
|
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
|
||||||
|
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||||
|
validate,
|
||||||
|
shardOps.ban,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/unban',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Clear an account ban (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
modAccess,
|
||||||
|
body('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
shardOps.unban,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/broadcast',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
modAccess,
|
||||||
|
body('text').isString().trim().isLength({ min: 1, max: 300 }),
|
||||||
|
body('hue').optional().isInt({ min: 0, max: 3000 }),
|
||||||
|
validate,
|
||||||
|
shardOps.broadcast,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/pages',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
modAccess,
|
||||||
|
shardOps.listPages,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/pages/:id/respond',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
modAccess,
|
||||||
|
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
body('message').isString().trim().isLength({ min: 1, max: 500 }),
|
||||||
|
body('close').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
shardOps.respondPage,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/pages/:id/close',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
modAccess,
|
||||||
|
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
shardOps.closePage,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/audit',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||||
|
modAccess,
|
||||||
|
shardOps.listAudit,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/houses',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
||||||
|
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
|
modAccess,
|
||||||
|
shardOps.listHouses,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Spawn atlas (admin only) ──────────────────────────────────────────
|
||||||
|
// Operating the atlas import. Admin-only rather than moderator: it reads a path
|
||||||
|
// on the server's filesystem and replaces every atlas table, which is closer to
|
||||||
|
// a deploy action than to moderation.
|
||||||
|
//
|
||||||
|
// These routes sit under /admin/shard even though the public ones deliberately
|
||||||
|
// do NOT sit under /public/shard. That is not an inconsistency: the public split
|
||||||
|
// says "this data does not come from the sidecar", while the admin panel is
|
||||||
|
// simply part of shard administration and belongs beside the rest of it.
|
||||||
|
shardRouter.get(
|
||||||
|
'/atlas',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||||||
|
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardAtlas.getStatus,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/atlas/import',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||||||
|
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('force').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
shardAtlas.importAtlas,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/atlas/approve',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||||||
|
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardAtlas.approve,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/atlas/reject',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||||||
|
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardAtlas.reject,
|
||||||
|
)
|
||||||
|
shardRouter.put(
|
||||||
|
'/atlas/path',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)'
|
||||||
|
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('path').isString().isLength({ max: 512 }),
|
||||||
|
validate,
|
||||||
|
shardAtlas.setPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||||||
|
// UO's id → display-string map, converted once by the operator from their own
|
||||||
|
// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason:
|
||||||
|
// it is static content derived from operator-supplied files rather than anything
|
||||||
|
// the sidecar sends, and operating it is shard administration.
|
||||||
|
//
|
||||||
|
// There is deliberately NO public counterpart. The table is never served as a
|
||||||
|
// table — 123k rows would dwarf any page that used it, and the Android client
|
||||||
|
// consumes the same already-resolved JSON. Names are applied server-side to the
|
||||||
|
// responses that need them.
|
||||||
|
shardRouter.get(
|
||||||
|
'/clilocs',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
|
||||||
|
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardClilocs.getStatus,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/clilocs/import',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
||||||
|
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('force').optional().isBoolean(),
|
||||||
|
body('approve').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
shardClilocs.importClilocs,
|
||||||
|
)
|
||||||
|
shardRouter.put(
|
||||||
|
'/clilocs/path',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Set the cliloc source the site reads from (admin only)'
|
||||||
|
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('path').isString().isLength({ max: 512 }),
|
||||||
|
validate,
|
||||||
|
shardClilocs.setPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Feature visibility (admin only) ───────────────────────────────────
|
||||||
|
// Who can see which shard surface, and which sensitive fields within it. This
|
||||||
|
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
|
||||||
|
shardRouter.get(
|
||||||
|
'/visibility',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
|
||||||
|
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardVisibility.getVisibility,
|
||||||
|
)
|
||||||
|
shardRouter.put(
|
||||||
|
'/visibility',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
|
||||||
|
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('features').isObject(),
|
||||||
|
validate,
|
||||||
|
shardVisibility.putVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = shardRouter
|
||||||
117
server/router/admin/shardAtlas.controller.js
Normal file
117
server/router/admin/shardAtlas.controller.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// ── Admin · Spawn atlas ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Operating the atlas import: where the ServUO tree is, whether it has drifted
|
||||||
|
// from what is loaded, and the approve/reject decision for a refresh that would
|
||||||
|
// remove a facet (docs/website/SPAWN_ATLAS.md).
|
||||||
|
//
|
||||||
|
// The policy lives in the model. This controller does three things and no more:
|
||||||
|
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||||
|
// records the action in the admin activity log.
|
||||||
|
//
|
||||||
|
// **A refresh result is not an exception.** `shardAtlas.refresh()` reports
|
||||||
|
// `unavailable` / `failed` / `needsReview` rather than throwing, because the boot
|
||||||
|
// path must never be stopped by a bad tree. That contract is preserved here: an
|
||||||
|
// unreadable mount is a 200 carrying `status: 'unavailable'`, not a 500. The
|
||||||
|
// admin needs to be told what is wrong with their path, and a 500 says only
|
||||||
|
// "something broke".
|
||||||
|
|
||||||
|
const atlas = require('../../model/shardAtlas/shardAtlas.model')
|
||||||
|
const { activity } = require('../../core')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('admin-shard-atlas')
|
||||||
|
|
||||||
|
// GET /admin/shard/atlas — what is loaded, what the tree looks like, what is
|
||||||
|
// staged. Unlike the public /atlas/meta route this DOES carry the filesystem
|
||||||
|
// path and the drift flag: that is the whole point of the panel.
|
||||||
|
async function getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await atlas.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getStatus', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/atlas/import — apply a map change without a restart.
|
||||||
|
//
|
||||||
|
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||||
|
// hatch for "the database is wrong but the tree is not"). Facet loss is still
|
||||||
|
// staged rather than applied — approving is a separate, explicit act.
|
||||||
|
async function importAtlas(req, res) {
|
||||||
|
try {
|
||||||
|
const force = !!req.body?.force
|
||||||
|
const result = await atlas.refresh({ force })
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'shard.atlas.import',
|
||||||
|
detail: { force, status: result.status, counts: result.counts ?? null },
|
||||||
|
})
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('importAtlas', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/atlas/approve — apply a staged refresh, facet loss and all.
|
||||||
|
//
|
||||||
|
// Re-parses the tree rather than applying something captured at boot: only the
|
||||||
|
// DECISION was stored, so what lands matches the tree as it is now. If the
|
||||||
|
// operator has since fixed a half-copied mount, the approved import is simply
|
||||||
|
// the corrected one — which is the desired outcome, not a surprise.
|
||||||
|
async function approve(req, res) {
|
||||||
|
try {
|
||||||
|
const result = await atlas.approvePending()
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'shard.atlas.approve',
|
||||||
|
detail: { status: result.status, removed: result.removedFacets ?? null },
|
||||||
|
})
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('approveAtlas', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/atlas/reject — keep the current atlas and remember the
|
||||||
|
// decision against those exact source hashes, so a declined refresh does not
|
||||||
|
// re-prompt on every restart. Changing the tree asks again.
|
||||||
|
async function reject(req, res) {
|
||||||
|
try {
|
||||||
|
const result = await atlas.rejectPending()
|
||||||
|
if (result.status === 'none') {
|
||||||
|
return res.status(404).json({ message: 'No refresh is awaiting review.' })
|
||||||
|
}
|
||||||
|
await activity.log({ req, action: 'shard.atlas.reject', detail: {} })
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('rejectAtlas', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/shard/atlas/path — point the atlas at a different ServUO tree.
|
||||||
|
//
|
||||||
|
// Persisted as a setting, which wins over the SERVUO_PATH env default so an
|
||||||
|
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||||
|
// the atlas off (boot skips, the loaded atlas keeps serving) — that is a
|
||||||
|
// legitimate thing to want, so it is allowed rather than validated away.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT import as a side effect: changing where the atlas reads
|
||||||
|
// from and reloading it are separate decisions, and an operator fixing a typo
|
||||||
|
// should not have a multi-thousand-row replace happen under them. The response
|
||||||
|
// carries the refreshed status so the panel can offer the import immediately.
|
||||||
|
async function setPath(req, res) {
|
||||||
|
try {
|
||||||
|
const value = String(req.body?.path ?? '').trim()
|
||||||
|
await atlas.setServuoPath(value, req.user?.id ?? null)
|
||||||
|
await activity.log({ req, action: 'shard.atlas.path', detail: { path: value } })
|
||||||
|
return res.json(await atlas.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('setAtlasPath', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, importAtlas, approve, reject, setPath }
|
||||||
106
server/router/admin/shardClilocs.controller.js
Normal file
106
server/router/admin/shardClilocs.controller.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// ── Admin · Cliloc table ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Operating the cliloc import: where the converted cliloc file is, whether it
|
||||||
|
// has drifted from what is loaded, and a forced reimport after a client patch
|
||||||
|
// (docs/website/CLILOCS.md).
|
||||||
|
//
|
||||||
|
// The policy lives in the model. This controller does three things and no more:
|
||||||
|
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||||
|
// records the action in the admin activity log.
|
||||||
|
//
|
||||||
|
// **A refresh result is not an exception.** `shardClilocs.refresh()` reports
|
||||||
|
// `unavailable` / `failed` rather than throwing, because the boot path must never
|
||||||
|
// be stopped by a bad file. That contract is preserved here: a missing file, or
|
||||||
|
// the single most likely operator mistake — pointing at the client's own
|
||||||
|
// COMPRESSED `Cliloc.enu` — is a 200 carrying `status: 'unavailable'` and the
|
||||||
|
// reason, not a 500. A 500 would say only "something broke"; the operator needs
|
||||||
|
// to be told which file to convert.
|
||||||
|
|
||||||
|
const clilocs = require('../../model/shardClilocs/shardClilocs.model')
|
||||||
|
const market = require('../../model/shardMarket/shardMarket.model')
|
||||||
|
const { activity } = require('../../core')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('admin-shard-clilocs')
|
||||||
|
|
||||||
|
// GET /admin/shard/clilocs — what is loaded, what the file looks like, whether
|
||||||
|
// they disagree. There is no public counterpart: the cliloc table is never
|
||||||
|
// served as a table, only applied to names the site already returns.
|
||||||
|
async function getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await clilocs.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getStatus', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/clilocs/import — reload after a client patch or a change to
|
||||||
|
// the shard's own overlay files, without a restart.
|
||||||
|
//
|
||||||
|
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||||
|
// hatch for "the database is wrong but the files are not").
|
||||||
|
//
|
||||||
|
// `approve` accepts a refresh in which a previously-loaded source has VANISHED.
|
||||||
|
// That is refused by default because an unmounted volume and a deliberate
|
||||||
|
// deletion look identical from the server — the lighter cousin of the atlas's
|
||||||
|
// approve/reject flow, and the reason it can be a flag here rather than a
|
||||||
|
// pending table is that nothing is stored to approve: the import re-reads the
|
||||||
|
// files at approval time by construction.
|
||||||
|
async function importClilocs(req, res) {
|
||||||
|
try {
|
||||||
|
const force = !!req.body?.force
|
||||||
|
const approve = !!req.body?.approve
|
||||||
|
const result = await clilocs.refresh({ force, approve })
|
||||||
|
|
||||||
|
// The marketplace denormalizes resolved item names into
|
||||||
|
// shard_vendor_items.display_name, and the shard's market sweep will NOT
|
||||||
|
// re-send an unchanged shop just because the site learned what its items are
|
||||||
|
// called — so without this pass, an operator who imports clilocs after the
|
||||||
|
// first sweep keeps seeing item ids until every shop happens to change.
|
||||||
|
// Awaited (rather than fired and forgotten) so the panel's "imported" is
|
||||||
|
// honest about the names being live; the pass is a bounded walk of one table
|
||||||
|
// and never throws.
|
||||||
|
if (result.status === 'imported') await market.refreshDisplayNames()
|
||||||
|
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'shard.clilocs.import',
|
||||||
|
detail: {
|
||||||
|
force,
|
||||||
|
approve,
|
||||||
|
status: result.status,
|
||||||
|
count: result.count ?? null,
|
||||||
|
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('importClilocs', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/shard/clilocs/path — point the site at a different cliloc file.
|
||||||
|
//
|
||||||
|
// Persisted as a setting, which wins over the UO_CLIENT_PATH env default so an
|
||||||
|
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||||
|
// resolution off (boot skips, the loaded table keeps serving) — a legitimate
|
||||||
|
// thing to want, so it is allowed rather than validated away.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT import as a side effect, for the same reason the atlas
|
||||||
|
// path does not: changing where the table reads from and reloading it are
|
||||||
|
// separate decisions. The response carries the refreshed status so the panel can
|
||||||
|
// offer the import immediately.
|
||||||
|
async function setPath(req, res) {
|
||||||
|
try {
|
||||||
|
const value = String(req.body?.path ?? '').trim()
|
||||||
|
await clilocs.setClientPath(value, req.user?.id ?? null)
|
||||||
|
await activity.log({ req, action: 'shard.clilocs.path', detail: { path: value } })
|
||||||
|
return res.json(await clilocs.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('setClilocPath', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, importClilocs, setPath }
|
||||||
171
server/router/admin/shardOps.controller.js
Normal file
171
server/router/admin/shardOps.controller.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// ── Admin: in-game staff operations (uo-link write plane + support queue) ────
|
||||||
|
//
|
||||||
|
// The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban /
|
||||||
|
// broadcast against the live shard, plus the help-page (support ticket) queue.
|
||||||
|
// Gated admin+moderator at the route (modAccess) — the sidecar trusts the
|
||||||
|
// loopback socket, so authorization is entirely the site's responsibility.
|
||||||
|
//
|
||||||
|
// SECURITY: `actor` (who is taking the action) is ALWAYS set here from the
|
||||||
|
// authenticated session (req.user.username), never from the request body, so an
|
||||||
|
// action can't be attributed to someone else. The shard records it in its console
|
||||||
|
// log, the ban's BanDealer tag, and the admin.audit event it echoes back.
|
||||||
|
|
||||||
|
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||||
|
const shardState = require('../../model/shardState/shardState.model')
|
||||||
|
const shardEvents = require('../../model/shardEvents/shardEvents.model')
|
||||||
|
const { activity } = require('../../core')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('admin-shard-ops')
|
||||||
|
|
||||||
|
// Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the
|
||||||
|
// success body. Mirrors the sidecar's documented status codes so the UI can tell a
|
||||||
|
// transient outage (503/504 — retry) from a real rejection (403/404).
|
||||||
|
function relay(res, result, okData) {
|
||||||
|
if (result.ok) return res.json(okData(result.data))
|
||||||
|
switch (result.status) {
|
||||||
|
case 400:
|
||||||
|
return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' })
|
||||||
|
case 403:
|
||||||
|
return res.status(403).json({
|
||||||
|
message:
|
||||||
|
(result.data && result.data.error) ||
|
||||||
|
'That action was refused — the target is protected, or the write plane is disabled on the shard.',
|
||||||
|
})
|
||||||
|
case 404:
|
||||||
|
return res.status(404).json({ message: 'No such account or target on the shard.' })
|
||||||
|
case 503:
|
||||||
|
case 504:
|
||||||
|
case 0:
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||||
|
default:
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/kick — disconnect every live session of an account (or serial).
|
||||||
|
async function kick(req, res) {
|
||||||
|
const { account, serial } = req.body
|
||||||
|
const actor = req.user.username
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.adminKick({ actor, account, serial })
|
||||||
|
if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } })
|
||||||
|
return relay(res, result, (d) => d || { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.kick', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite.
|
||||||
|
async function ban(req, res) {
|
||||||
|
const { account, serial, durationSec, reason } = req.body
|
||||||
|
const actor = req.user.username
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason })
|
||||||
|
if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } })
|
||||||
|
return relay(res, result, (d) => d || { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.ban', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/unban — clear an account's ban.
|
||||||
|
async function unban(req, res) {
|
||||||
|
const { account } = req.body
|
||||||
|
const actor = req.user.username
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.adminUnban({ actor, account })
|
||||||
|
if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } })
|
||||||
|
return relay(res, result, (d) => d || { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.unban', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/broadcast — a system message to everyone online.
|
||||||
|
async function broadcast(req, res) {
|
||||||
|
const { text, hue } = req.body
|
||||||
|
const actor = req.user.username
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.adminBroadcast({ actor, text, hue })
|
||||||
|
if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } })
|
||||||
|
return relay(res, result, (d) => d || { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.broadcast', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/shard/pages — the open help-page (support) queue, from our store.
|
||||||
|
async function listPages(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await shardState.listPages())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.listPages', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/pages/:id/respond — reply to a player (optionally close).
|
||||||
|
async function respondPage(req, res) {
|
||||||
|
const { id } = req.params
|
||||||
|
const { message, close } = req.body
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) })
|
||||||
|
if (result.ok) {
|
||||||
|
await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } })
|
||||||
|
// Close removes the page from the queue; reflect it locally at once (the
|
||||||
|
// page.closed event will confirm it, but the UI shouldn't wait a poll cycle).
|
||||||
|
if (close) await shardState.removePage(id).catch(() => {})
|
||||||
|
}
|
||||||
|
return relay(res, result, (d) => d || { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.respondPage', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/pages/:id/close — resolve a page without a reply.
|
||||||
|
async function closePage(req, res) {
|
||||||
|
const { id } = req.params
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.closePage(id)
|
||||||
|
if (result.ok) {
|
||||||
|
await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } })
|
||||||
|
await shardState.removePage(id).catch(() => {})
|
||||||
|
}
|
||||||
|
return relay(res, result, (d) => d || { ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.closePage', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/shard/audit — recent moderation audit events (admin.audit), from the
|
||||||
|
// ingested event log. Seeds the live audit log the panel keeps current over SSE.
|
||||||
|
async function listAudit(req, res) {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit
|
||||||
|
return res.json(await shardEvents.list({ kind: 'admin.audit', limit }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.listAudit', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
|
||||||
|
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
|
||||||
|
// houses with location; this is the complete board, kept live for staff on the
|
||||||
|
// admin SSE channel (house.update / house.remove).
|
||||||
|
async function listHouses(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await shardState.listHouses())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shardOps.listHouses', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }
|
||||||
98
server/router/admin/shardVisibility.controller.js
Normal file
98
server/router/admin/shardVisibility.controller.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// ── Admin · Shard visibility ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Read/write the per-feature audience config that gates every shard-derived
|
||||||
|
// surface. Admin-only: this decides what anonymous visitors can see, so it is
|
||||||
|
// not part of the moderator tier.
|
||||||
|
//
|
||||||
|
// The policy itself (the ladder, the feature catalog, which fields are locked)
|
||||||
|
// lives in utils/shardVisibility.js. This controller only validates input
|
||||||
|
// against that policy and persists it.
|
||||||
|
|
||||||
|
const model = require('../../model/shardVisibility/shardVisibility.model')
|
||||||
|
const visibility = require('../../utils/shardVisibility')
|
||||||
|
const log = require('../../core').logger('admin-shard-visibility')
|
||||||
|
|
||||||
|
// GET /admin/shard/visibility — the effective config (defaults merged with any
|
||||||
|
// stored overrides), plus the vocabulary the admin UI needs to render itself:
|
||||||
|
// the ladder, and which fields each feature exposes as configurable.
|
||||||
|
async function getVisibility(req, res) {
|
||||||
|
try {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
return res.json({
|
||||||
|
ladder: visibility.LADDER,
|
||||||
|
lockedFields: Object.keys(visibility.LOCKED_FIELDS),
|
||||||
|
defaults: visibility.compileDefaults(),
|
||||||
|
features: config,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getVisibility', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/shard/visibility — replace the settings for one or more features.
|
||||||
|
// Body: { features: { <name>: { enabled, audience, stream, fieldRules } } }
|
||||||
|
//
|
||||||
|
// Rejects unknown feature names, unknown rungs, and any attempt to configure a
|
||||||
|
// locked field — a 400 rather than a silent drop, so an admin who tries to make
|
||||||
|
// `acct` public learns that it is not negotiable.
|
||||||
|
async function putVisibility(req, res) {
|
||||||
|
try {
|
||||||
|
const incoming = req.body?.features
|
||||||
|
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
||||||
|
return res.status(400).json({ message: 'features object required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (const [name, patch] of Object.entries(incoming)) {
|
||||||
|
if (!visibility.isFeature(name)) {
|
||||||
|
return res.status(400).json({ message: `Unknown feature: ${name}` })
|
||||||
|
}
|
||||||
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||||
|
return res.status(400).json({ message: `Invalid settings for ${name}` })
|
||||||
|
}
|
||||||
|
if (patch.audience != null && !visibility.isLevel(patch.audience)) {
|
||||||
|
return res.status(400).json({ message: `Unknown audience for ${name}: ${patch.audience}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldRules = {}
|
||||||
|
for (const [field, level] of Object.entries(patch.fieldRules || {})) {
|
||||||
|
// Matches flattened spellings too (`ownerAcct`, `leaderWebId`), so the
|
||||||
|
// rejection covers every way the field can be named rather than the two
|
||||||
|
// canonical keys.
|
||||||
|
if (visibility.isLockedField(field)) {
|
||||||
|
return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` })
|
||||||
|
}
|
||||||
|
if (!visibility.isLevel(level)) {
|
||||||
|
return res.status(400).json({ message: `Unknown rung for ${name}.${field}: ${level}` })
|
||||||
|
}
|
||||||
|
fieldRules[field] = level
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (await visibility.getConfig())[name]
|
||||||
|
entries.push({
|
||||||
|
feature: name,
|
||||||
|
enabled: patch.enabled == null ? current.enabled : !!patch.enabled,
|
||||||
|
audience: patch.audience ?? current.audience,
|
||||||
|
stream: patch.stream == null ? current.stream : !!patch.stream,
|
||||||
|
fieldRules,
|
||||||
|
updatedBy: req.user?.id ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) await model.upsert(entry)
|
||||||
|
visibility.invalidate()
|
||||||
|
|
||||||
|
log.info('shard visibility updated', {
|
||||||
|
by: req.user?.id,
|
||||||
|
features: entries.map((e) => e.feature),
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ features: await visibility.getConfig() })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('putVisibility', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getVisibility, putVisibility }
|
||||||
123
server/router/admin/uoLink.controller.js
Normal file
123
server/router/admin/uoLink.controller.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// ── Admin: uo-link sidecar control ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret
|
||||||
|
// token, protocol pin, enabled) and drive the town crier. SECURITY: the token
|
||||||
|
// is write-only over this API — stored encrypted, NEVER returned; responses
|
||||||
|
// expose only `hasToken` (same convention as the Discord bot token). Saving
|
||||||
|
// (re)starts the WS ingest client so a change takes effect with no redeploy.
|
||||||
|
|
||||||
|
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||||
|
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
||||||
|
const shardBroadcast = require('../../utils/shardBroadcast')
|
||||||
|
const { activity } = require('../../core')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('admin-uolink')
|
||||||
|
|
||||||
|
// Assemble the masked config + live health + ingestion stats for the panel.
|
||||||
|
async function buildStatus() {
|
||||||
|
const config = await uoLinkConfig.getSafe()
|
||||||
|
const health = await uoLinkClient.health()
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
||||||
|
ingest: uoLinkSocket.getState(),
|
||||||
|
sse: shardBroadcast.stats(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/uo-link/config — masked config + live status + ingestion stats.
|
||||||
|
async function getConfig(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await buildStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.getConfig', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/uo-link/config — save connection settings + (re)start the socket.
|
||||||
|
async function saveConfig(req, res) {
|
||||||
|
const { baseUrl, wsUrl, token, protocol, enabled } = req.body
|
||||||
|
try {
|
||||||
|
const current = await uoLinkConfig.getSafe()
|
||||||
|
const willHaveToken = Boolean(token) || current.hasToken
|
||||||
|
if (enabled && !willHaveToken) {
|
||||||
|
return res.status(400).json({ message: 'An auth token is required before enabling.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await uoLinkConfig.save({
|
||||||
|
baseUrl,
|
||||||
|
wsUrl,
|
||||||
|
token,
|
||||||
|
protocol: protocol !== undefined ? Number(protocol) : undefined,
|
||||||
|
enabled,
|
||||||
|
updatedBy: req.user.id,
|
||||||
|
})
|
||||||
|
// Drop the client's cached config so the health check below uses the new values.
|
||||||
|
uoLinkClient.invalidateConfig()
|
||||||
|
|
||||||
|
// (Re)start or stop the ingest socket to match the new enabled/URL/token.
|
||||||
|
const saved = await uoLinkConfig.getSafe()
|
||||||
|
if (saved.enabled && saved.hasToken) {
|
||||||
|
await uoLinkSocket.start()
|
||||||
|
} else {
|
||||||
|
uoLinkSocket.stop()
|
||||||
|
await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } })
|
||||||
|
log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled })
|
||||||
|
return res.json(await buildStatus())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.saveConfig', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
|
||||||
|
async function postTownCrier(req, res) {
|
||||||
|
const { id, lines, durationSec } = req.body
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.postTownCrier({ id, lines, durationSec })
|
||||||
|
if (result.ok) {
|
||||||
|
await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } })
|
||||||
|
return res.json(result.data || { ok: true, id })
|
||||||
|
}
|
||||||
|
if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.postTownCrier', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message.
|
||||||
|
async function deleteTownCrier(req, res) {
|
||||||
|
const { id } = req.params
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.deleteTownCrier(id)
|
||||||
|
if (result.ok) {
|
||||||
|
await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } })
|
||||||
|
return res.json(result.data || { ok: true, id })
|
||||||
|
}
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uoLink.deleteTownCrier', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only.
|
||||||
|
function stream(req, res) {
|
||||||
|
shardBroadcast.subscribe(req, res, 'admin')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getConfig, saveConfig, postTownCrier, deleteTownCrier, stream }
|
||||||
100
server/router/admin/uoLink.router.js
Normal file
100
server/router/admin/uoLink.router.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Admin · uo-link — the sidecar connection config, the town crier, and the
|
||||||
|
// staff SSE stream.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/admin/uo-link by admin/index.js, which already applied
|
||||||
|
// `noindex, isLoggedIn, staffOnly`. This is where shard integration is
|
||||||
|
// configured: base/ws URL, bearer token, protocol version and the enabled
|
||||||
|
// toggle all live in the DB (uoLinkConfig), never in env. The token is
|
||||||
|
// write-only over this API (SECURITY note in uoLink.controller.js).
|
||||||
|
//
|
||||||
|
// /stream is the ADMIN SSE channel — it carries staff audit, cheat detection
|
||||||
|
// and login attempts on top of the public event kinds. The public/admin
|
||||||
|
// allowlist split in utils/shardIngest.js is a security boundary; the adminOnly
|
||||||
|
// gate below is its other half.
|
||||||
|
//
|
||||||
|
// The routes keep their `Admin · Shard` swagger tag: retagging is a real
|
||||||
|
// OpenAPI diff and does not belong in a route-move PR.
|
||||||
|
//
|
||||||
|
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||||
|
// the middleware chain each route carries is unchanged by the move.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const { body, param } = core.validator
|
||||||
|
|
||||||
|
const uoLink = require('./uoLink.controller')
|
||||||
|
const { requireRole, validate } = core.middleware
|
||||||
|
|
||||||
|
const uoLinkRouter = express.Router()
|
||||||
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
uoLinkRouter.get(
|
||||||
|
'/config',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
uoLink.getConfig,
|
||||||
|
)
|
||||||
|
uoLinkRouter.put(
|
||||||
|
'/config',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
||||||
|
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||||||
|
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
||||||
|
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||||||
|
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
||||||
|
body('enabled').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
uoLink.saveConfig,
|
||||||
|
)
|
||||||
|
uoLinkRouter.post(
|
||||||
|
'/towncrier',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||||
|
body('lines').isArray({ min: 1, max: 8 }),
|
||||||
|
body('lines.*').isString().isLength({ max: 200 }),
|
||||||
|
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
||||||
|
validate,
|
||||||
|
uoLink.postTownCrier,
|
||||||
|
)
|
||||||
|
uoLinkRouter.delete(
|
||||||
|
'/towncrier/:id',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||||
|
validate,
|
||||||
|
uoLink.deleteTownCrier,
|
||||||
|
)
|
||||||
|
uoLinkRouter.get(
|
||||||
|
'/stream',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
||||||
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
|
adminOnly,
|
||||||
|
uoLink.stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = uoLinkRouter
|
||||||
129
server/router/admin/usersShard.controller.js
Normal file
129
server/router/admin/usersShard.controller.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
|
||||||
|
//
|
||||||
|
// Backs the /admin/users/:id detail page. Every read is scoped to the target
|
||||||
|
// user's linked game accounts (from the local shard_account_links mirror): their
|
||||||
|
// vendor sales, houses, and currently-online characters. The live character
|
||||||
|
// rosters are fetched separately by the client through the existing admin-bypass
|
||||||
|
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
|
||||||
|
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
|
||||||
|
|
||||||
|
const { users, activity } = require('../../core')
|
||||||
|
const shardLinks = require('../../model/shardLinks/shardLinks.model')
|
||||||
|
const shardState = require('../../model/shardState/shardState.model')
|
||||||
|
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||||
|
const { salesForAccounts } = require('../../utils/shardSales')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('admin-user-shard')
|
||||||
|
|
||||||
|
// Resolve the target user's linked game accounts, or null if the user id is
|
||||||
|
// unknown (so the handler can 404 rather than silently returning an empty set).
|
||||||
|
async function accountsForUser(id) {
|
||||||
|
const user = await users.getById(id)
|
||||||
|
if (!user) return null
|
||||||
|
const links = await shardLinks.listForUser(id)
|
||||||
|
return { user, links, accounts: links.map((l) => l.account) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
||||||
|
async function listAccounts(req, res) {
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
return res.json(ctx.links)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('listAccounts', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
|
||||||
|
async function getSales(req, res) {
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
return res.json(await salesForAccounts(ctx.accounts))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getSales', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
|
||||||
|
async function getHouses(req, res) {
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getHouses', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id/shard/online — the user's characters currently online.
|
||||||
|
async function getOnline(req, res) {
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getOnline', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
|
||||||
|
// city governorships they currently hold and guilds they lead. Both are reliable
|
||||||
|
// current-state lookups on the user's linked accounts.
|
||||||
|
async function getStanding(req, res) {
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
const [governorOf, guildsLed] = await Promise.all([
|
||||||
|
shardState.listGovernorshipsForAccounts(ctx.accounts),
|
||||||
|
shardState.listGuildsLedForAccounts(ctx.accounts),
|
||||||
|
])
|
||||||
|
return res.json({ governorOf, guildsLed })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getStanding', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
|
||||||
|
// user, site-side. `actor` is stamped from the session (never the browser). On
|
||||||
|
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
|
||||||
|
// local mirror so attribution stops immediately.
|
||||||
|
async function unlinkAccount(req, res) {
|
||||||
|
const { account } = req.params
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
|
||||||
|
if (!ctx.accounts.includes(account)) {
|
||||||
|
return res.status(404).json({ message: 'That account is not linked to this user.' })
|
||||||
|
}
|
||||||
|
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
|
||||||
|
if (result.ok) {
|
||||||
|
await shardLinks.removeByAccount(account)
|
||||||
|
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
|
||||||
|
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
|
||||||
|
return res.json({ account, unlinked: true })
|
||||||
|
}
|
||||||
|
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
|
||||||
|
if (result.status === 404) {
|
||||||
|
// Not linked on the shard — reconcile our mirror anyway so the two agree.
|
||||||
|
await shardLinks.removeByAccount(account)
|
||||||
|
return res.status(404).json({ message: 'That account is not linked.' })
|
||||||
|
}
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('unlinkAccount', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||||
113
server/router/admin/usersShard.router.js
Normal file
113
server/router/admin/usersShard.router.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// ── The `admin.users.detail` extension slot's contents ─────────────────────
|
||||||
|
//
|
||||||
|
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.9 named the
|
||||||
|
// fourth mount shape: module routes hanging off a CORE resource. These six paths
|
||||||
|
// are shard reads on `/admin/users/:id`, a user-management URL core owns, so
|
||||||
|
// they cannot move with a prefix and cannot stay where they are either.
|
||||||
|
//
|
||||||
|
// The resolution is an extension SLOT. `users.router.js` declares
|
||||||
|
// `admin.users.detail` and mounts its router at `/:id`; this file is what fills
|
||||||
|
// it, registered through modules/registries.js like a module would
|
||||||
|
// (registerCore() → `api.registerExtension('admin.users.detail', …)`). Phase 3
|
||||||
|
// moves this file to module-uo and changes nothing else — the six URLs are
|
||||||
|
// identical either way, and core never learns what "shard" means.
|
||||||
|
//
|
||||||
|
// `mergeParams` comes from the slot's router, so `req.params.id` is the parent's
|
||||||
|
// user id. Core's own routes on the resource are declared BEFORE the slot is
|
||||||
|
// mounted, so core always wins a path conflict (MODULE_API.md §2.4).
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const { param } = core.validator
|
||||||
|
|
||||||
|
const usersShard = require('./usersShard.controller')
|
||||||
|
const { validate } = core.middleware
|
||||||
|
|
||||||
|
// Same shape the shard routes validate account names with.
|
||||||
|
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
|
||||||
|
const shardRouter = express.Router({ mergeParams: true })
|
||||||
|
|
||||||
|
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||||
|
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||||
|
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||||
|
// already grants admins a bypass to any account), so no routes for them here.
|
||||||
|
shardRouter.get(
|
||||||
|
'/shard/accounts',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.listAccounts,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/shard/sales',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getSales,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/shard/houses',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getHouses,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/shard/online',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getOnline,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/shard/standing',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getStanding,
|
||||||
|
)
|
||||||
|
shardRouter.delete(
|
||||||
|
'/shard/link/:account',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||||
|
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
usersShard.unlinkAccount,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = shardRouter
|
||||||
273
server/router/player/shard.controller.js
Normal file
273
server/router/player/shard.controller.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// ── Player: game-account linking + reads ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// The player-facing surface for the uo-link integration. A logged-in player
|
||||||
|
// runs [link in game, gets a one-time code, and enters it here — the server
|
||||||
|
// confirms it with the sidecar (which permanently tags the game account with the
|
||||||
|
// website user id) and mirrors the link locally. Roster/vendor reads are
|
||||||
|
// ownership-checked against that mirror so a player can only see accounts they
|
||||||
|
// have linked. The sidecar token stays server-side throughout.
|
||||||
|
|
||||||
|
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||||
|
const shardLinks = require('../../model/shardLinks/shardLinks.model')
|
||||||
|
const shardState = require('../../model/shardState/shardState.model')
|
||||||
|
const shardClilocs = require('../../model/shardClilocs/shardClilocs.model')
|
||||||
|
const { settings, activity } = require('../../core')
|
||||||
|
const { salesForAccounts } = require('../../utils/shardSales')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('player-shard')
|
||||||
|
|
||||||
|
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cliloc ids on a profile into display names.
|
||||||
|
*
|
||||||
|
* Items on the wire carry a `LabelNumber`, not a name — `BridgeProfile.WriteItem`
|
||||||
|
* sends `cliloc` on every equipment entry and `name` only for the minority of
|
||||||
|
* items a player has renamed. Reward titles are the same shape: the shard sends
|
||||||
|
* a cliloc number as a string, which the sheet previously had to SKIP because it
|
||||||
|
* had no way to turn it into words.
|
||||||
|
*
|
||||||
|
* Resolution happens here rather than in the browser because the table is ~123k
|
||||||
|
* rows: shipping it to render a dozen names would dwarf the page, and the
|
||||||
|
* Android client consumes this same JSON and would otherwise need its own copy.
|
||||||
|
*
|
||||||
|
* A shard with no cliloc table configured resolves nothing and the sheet renders
|
||||||
|
* ids exactly as it did before — this is decoration, and it is applied in the
|
||||||
|
* same best-effort block as the guild/governor cross-links.
|
||||||
|
*/
|
||||||
|
async function resolveProfileClilocs(profile) {
|
||||||
|
const wanted = []
|
||||||
|
|
||||||
|
const equipment = Array.isArray(profile.equipment) ? profile.equipment : []
|
||||||
|
for (const item of equipment) {
|
||||||
|
if (Number.isInteger(item?.cliloc)) wanted.push(item.cliloc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward titles arrive as strings that may be either a literal ("Knight of
|
||||||
|
// Trinsic") or a cliloc number in string form. Only the numeric ones need us.
|
||||||
|
const reward = Array.isArray(profile.titles?.reward) ? profile.titles.reward : []
|
||||||
|
const rewardNumbers = reward.map((r) => (/^\d+$/.test(String(r)) ? Number(r) : null))
|
||||||
|
for (const n of rewardNumbers) if (n !== null) wanted.push(n)
|
||||||
|
|
||||||
|
if (wanted.length === 0) return
|
||||||
|
|
||||||
|
const names = await shardClilocs.resolveMany(wanted)
|
||||||
|
if (names.size === 0) return
|
||||||
|
|
||||||
|
for (const item of equipment) {
|
||||||
|
// A player-given name always wins over the type name: an item called "Bob's
|
||||||
|
// lucky axe" should not be relabelled "hatchet".
|
||||||
|
if (item?.name) continue
|
||||||
|
const resolved = names.get(item?.cliloc)
|
||||||
|
if (resolved) item.clilocName = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewardNumbers.some((n) => n !== null)) {
|
||||||
|
profile.titles.rewardResolved = reward.map((raw, i) => {
|
||||||
|
const n = rewardNumbers[i]
|
||||||
|
return n === null ? String(raw) : names.get(n) ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decorate a char.profile with cross-links from our own board data: the guild the
|
||||||
|
// character leads and any city governorship on its account, plus resolved cliloc
|
||||||
|
// names. Best-effort — a failure here never fails the profile (it's a nicety,
|
||||||
|
// not the sheet).
|
||||||
|
async function enrichCharProfile(profile) {
|
||||||
|
if (!profile) return profile
|
||||||
|
try {
|
||||||
|
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
|
||||||
|
if (guild) profile.guild = guild
|
||||||
|
if (profile.acct) {
|
||||||
|
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
||||||
|
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||||
|
}
|
||||||
|
await resolveProfileClilocs(profile)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /player/shard/link — confirm an in-game link code.
|
||||||
|
async function link(req, res) {
|
||||||
|
const { code } = req.body
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.confirmLink(code, req.user.id)
|
||||||
|
|
||||||
|
if (result.ok && result.data && result.data.kind === 'link.ok') {
|
||||||
|
const account = result.data.account
|
||||||
|
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
|
||||||
|
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
|
||||||
|
log.info('player linked game account', { user: req.user.username, account })
|
||||||
|
return res.json({ linked: true, account })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidecar reports bad/expired codes as 400 link.error or 404.
|
||||||
|
if (result.status === 400 || result.status === 404) {
|
||||||
|
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
|
||||||
|
}
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.link', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/accounts — the caller's linked game accounts.
|
||||||
|
async function listAccounts(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await shardLinks.listForUser(req.user.id))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.listAccounts', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins may view any character's data; everyone else is limited to accounts
|
||||||
|
// they have personally linked. The same handlers back /player/shard (role
|
||||||
|
// `player`, never admin) and /admin/shard (staff), so this bypass only ever
|
||||||
|
// widens access for genuine admins.
|
||||||
|
const isAdmin = (req) => req.user && req.user.role === 'admin'
|
||||||
|
|
||||||
|
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
||||||
|
// uoLinkClient method to call with the account.
|
||||||
|
async function ownedRoundTrip(req, res, fetcher, label) {
|
||||||
|
const { account } = req.params
|
||||||
|
try {
|
||||||
|
const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id))
|
||||||
|
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
||||||
|
|
||||||
|
const result = await fetcher(account)
|
||||||
|
if (result.ok) return res.json(result.data)
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`player.shard.${label}`, err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/roster/:account — characters on a linked account.
|
||||||
|
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
|
||||||
|
|
||||||
|
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
||||||
|
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
||||||
|
|
||||||
|
// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
|
||||||
|
// account is linked to the caller. The sidecar returns the owning account in the
|
||||||
|
// profile, which we check against the caller's links before returning anything.
|
||||||
|
async function getChar(req, res) {
|
||||||
|
const { serial } = req.params
|
||||||
|
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
|
||||||
|
try {
|
||||||
|
const result = await uoLinkClient.getCharBySerial(serial)
|
||||||
|
if (result.ok) {
|
||||||
|
// Admins see any character; others only characters on an account they linked.
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
const acct = result.data && result.data.acct
|
||||||
|
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
||||||
|
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
||||||
|
}
|
||||||
|
return res.json(await enrichCharProfile(result.data))
|
||||||
|
}
|
||||||
|
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||||
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
||||||
|
}
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getChar', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/sales — recent player-vendor sales for the caller's linked
|
||||||
|
// accounts only (as seller/owner). Read from the site's own event log.
|
||||||
|
async function getSales(req, res) {
|
||||||
|
try {
|
||||||
|
const links = await shardLinks.listForUser(req.user.id)
|
||||||
|
const accounts = links.map((l) => l.account)
|
||||||
|
return res.json(await salesForAccounts(accounts))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getSales', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
|
||||||
|
// their linked accounts. A player sees their own decay/IDOC standing; never
|
||||||
|
// anyone else's. Full detail is fine here — it's their property.
|
||||||
|
async function getHouses(req, res) {
|
||||||
|
try {
|
||||||
|
const links = await shardLinks.listForUser(req.user.id)
|
||||||
|
const accounts = links.map((l) => l.account)
|
||||||
|
return res.json(await shardState.listHousesForAccounts(accounts))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.getHouses', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
|
||||||
|
// The password is never echoed anywhere; only the mapped reason is returned.
|
||||||
|
function mapCreateAccountError(res, result) {
|
||||||
|
const reason = (result.data && result.data.reason) || ''
|
||||||
|
switch (result.status) {
|
||||||
|
case 409:
|
||||||
|
return res.status(409).json({ message: 'That account name is already taken.' })
|
||||||
|
case 429:
|
||||||
|
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
|
||||||
|
case 403:
|
||||||
|
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
|
||||||
|
case 400:
|
||||||
|
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
|
||||||
|
case 503:
|
||||||
|
case 0:
|
||||||
|
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||||
|
default:
|
||||||
|
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /player/shard/account — provision a GAME account for the signed-in website
|
||||||
|
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
|
||||||
|
// invite-accept "create game account" step alike (both act as the signed-in user).
|
||||||
|
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
|
||||||
|
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
|
||||||
|
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
|
||||||
|
async function createGameAccount(req, res) {
|
||||||
|
const { account, password } = req.body
|
||||||
|
try {
|
||||||
|
if (!(await settings.isGameAccountSignupEnabled())) {
|
||||||
|
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
||||||
|
}
|
||||||
|
const result = await uoLinkClient.createAccount({
|
||||||
|
actor: req.user.username,
|
||||||
|
account,
|
||||||
|
password,
|
||||||
|
websiteUserId: req.user.id,
|
||||||
|
ip: req.ip,
|
||||||
|
})
|
||||||
|
if (result.ok) {
|
||||||
|
// Mirror the link locally so the portal lists the account immediately.
|
||||||
|
await shardLinks.link({ account, userId: req.user.id })
|
||||||
|
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
|
||||||
|
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
|
||||||
|
return res.status(201).json({ account, linked: true })
|
||||||
|
}
|
||||||
|
return mapCreateAccountError(res, result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('player.shard.createGameAccount', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }
|
||||||
125
server/router/player/shard.router.js
Normal file
125
server/router/player/shard.router.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
// Player · Shard — game-account linking and the caller's own roster / vendors /
|
||||||
|
// characters / sales / houses, ownership-checked against the local link mirror.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/player/shard by player/index.js, which already applied
|
||||||
|
// `noindex, requireAuth`. No extra gate: every handler is self-scoped to
|
||||||
|
// req.user.id.
|
||||||
|
//
|
||||||
|
// These are the *same* handlers (player/shard.controller) that admin/shard.router.js
|
||||||
|
// serves under /admin/shard for the seven self-service routes — staff are a
|
||||||
|
// superset of players, and the controller keys off req.user.id either way. Two
|
||||||
|
// URL surfaces, one implementation.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const { body, param } = core.validator
|
||||||
|
|
||||||
|
const shard = require('./shard.controller')
|
||||||
|
const { validate, accountChangeLimiter } = core.middleware
|
||||||
|
|
||||||
|
const shardRouter = express.Router()
|
||||||
|
|
||||||
|
// Link an in-game account with a one-time code from [link, then read the
|
||||||
|
// account's roster / vendors (ownership-checked against the local link mirror).
|
||||||
|
const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
|
||||||
|
shardRouter.post(
|
||||||
|
'/link',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Link an in-game account with a one-time code'
|
||||||
|
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||||
|
validate,
|
||||||
|
shard.link,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/account',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
|
||||||
|
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
|
||||||
|
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
accountChangeLimiter,
|
||||||
|
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||||
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
|
validate,
|
||||||
|
shard.createGameAccount,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/accounts',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'List the caller’s linked game accounts'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
shard.listAccounts,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/roster/:account',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Character roster for a linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
shard.roster,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/vendors/:account',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Player vendors for a linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
shard.vendors,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/char/:serial',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Character sheet — only for a character on the caller’s linked account'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||||
|
validate,
|
||||||
|
shard.getChar,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/sales',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
shard.getSales,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/houses',
|
||||||
|
// #swagger.tags = ['Player · Shard']
|
||||||
|
// #swagger.summary = 'The caller’s own houses (home status)'
|
||||||
|
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
|
shard.getHouses,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = shardRouter
|
||||||
134
server/router/public/atlas.controller.js
Normal file
134
server/router/public/atlas.controller.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// ── Public: the spawn atlas ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A browsable catalogue of what the shard CONTAINS — which creatures spawn,
|
||||||
|
// where, how many, and which champion altars are configured. Everything here is
|
||||||
|
// a plain indexed read of the tables the boot-time import fills from the shard's
|
||||||
|
// own ServUO tree (docs/website/SPAWN_ATLAS.md).
|
||||||
|
//
|
||||||
|
// Two properties separate this from /public/shard/*:
|
||||||
|
//
|
||||||
|
// • **Nothing touches the sidecar.** The atlas is static shard content, not
|
||||||
|
// live shard state, so these pages stay fully populated while the shard is
|
||||||
|
// down. That is why the routes are mounted at /public/atlas and are
|
||||||
|
// siteMode-gated like /posts and /wiki, rather than under /shard.
|
||||||
|
// • **The live champion feed is a different thing.** `/atlas/champions` is the
|
||||||
|
// configured roster ("there is an Unholy Terror altar in Deceit");
|
||||||
|
// `/shard/champs` is the running state ("it is on level 3 right now").
|
||||||
|
//
|
||||||
|
// Every response is still passed through `projectFeature` for the `atlas`
|
||||||
|
// feature. It declares no sensitive fields today, so the projection is a
|
||||||
|
// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and
|
||||||
|
// not projecting is a bug, and the cost of honouring it is one call per handler
|
||||||
|
// rather than a retrofit the first time a field needs gating.
|
||||||
|
|
||||||
|
const atlas = require('../../model/shardAtlas/shardAtlas.model')
|
||||||
|
const visibility = require('../../utils/shardVisibility')
|
||||||
|
|
||||||
|
const log = require('../../core').logger('public-atlas')
|
||||||
|
|
||||||
|
const FEATURE = 'atlas'
|
||||||
|
|
||||||
|
// Query params arrive as strings; express-validator has already bounded them.
|
||||||
|
const int = (value, fallback) => {
|
||||||
|
const n = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||||
|
|
||||||
|
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
|
||||||
|
async function getCreatures(req, res) {
|
||||||
|
try {
|
||||||
|
const page = await atlas.searchCreatures({
|
||||||
|
q: str(req.query.q),
|
||||||
|
facet: str(req.query.facet),
|
||||||
|
limit: int(req.query.limit, 50),
|
||||||
|
offset: int(req.query.offset, 0),
|
||||||
|
})
|
||||||
|
return res.json(await visibility.project(FEATURE, page, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('atlas.getCreatures', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
|
||||||
|
//
|
||||||
|
// 404 means "no such creature in this atlas", which also covers "the atlas has
|
||||||
|
// never been imported" — an empty atlas has no slugs, and there is nothing more
|
||||||
|
// specific to say to an anonymous caller.
|
||||||
|
async function getCreature(req, res) {
|
||||||
|
try {
|
||||||
|
const creature = await atlas.getCreature(req.params.slug, {
|
||||||
|
facet: str(req.query.facet),
|
||||||
|
points: int(req.query.points, 200),
|
||||||
|
})
|
||||||
|
if (!creature) return res.status(404).json({ message: 'Not Found' })
|
||||||
|
return res.json(await visibility.project(FEATURE, creature, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('atlas.getCreature', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/atlas/regions?facet=&q=
|
||||||
|
async function getRegions(req, res) {
|
||||||
|
try {
|
||||||
|
const regions = await atlas.listRegions({
|
||||||
|
facet: str(req.query.facet),
|
||||||
|
q: str(req.query.q),
|
||||||
|
})
|
||||||
|
return res.json(await visibility.project(FEATURE, regions, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('atlas.getRegions', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/atlas/landmarks?facet=&q=
|
||||||
|
async function getLandmarks(req, res) {
|
||||||
|
try {
|
||||||
|
const landmarks = await atlas.listLandmarks({
|
||||||
|
facet: str(req.query.facet),
|
||||||
|
q: str(req.query.q),
|
||||||
|
})
|
||||||
|
return res.json(await visibility.project(FEATURE, landmarks, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('atlas.getLandmarks', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
|
||||||
|
async function getChampions(req, res) {
|
||||||
|
try {
|
||||||
|
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
|
||||||
|
return res.json(await visibility.project(FEATURE, champions, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('atlas.getChampions', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
|
||||||
|
//
|
||||||
|
// Public-safe by construction: the model omits the ServUO path, the per-file
|
||||||
|
// hashes and the pending-refresh state, all of which describe the operator's
|
||||||
|
// filesystem rather than the game world. The admin status route carries those.
|
||||||
|
async function getMeta(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('atlas.getMeta', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCreatures,
|
||||||
|
getCreature,
|
||||||
|
getRegions,
|
||||||
|
getLandmarks,
|
||||||
|
getChampions,
|
||||||
|
getMeta,
|
||||||
|
}
|
||||||
129
server/router/public/atlas.router.js
Normal file
129
server/router/public/atlas.router.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from
|
||||||
|
// the shard's own ServUO tree, not live shard state.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences
|
||||||
|
// from the /public/shard routes next door (docs/link/v3.md §6):
|
||||||
|
//
|
||||||
|
// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages
|
||||||
|
// stay fully populated while the shard is down. Mounting it under /shard
|
||||||
|
// would imply a dependency it does not have.
|
||||||
|
// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt
|
||||||
|
// because shard status is wanted *during* maintenance; a bestiary is site
|
||||||
|
// content and follows site content's rules.
|
||||||
|
//
|
||||||
|
// Every route also carries `requireFeature('atlas')` — 404 when an admin has
|
||||||
|
// disabled the feature, 403 when the caller sits below its configured audience.
|
||||||
|
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||||
|
// changes something.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const { param, query } = core.validator
|
||||||
|
|
||||||
|
const atlas = require('./atlas.controller')
|
||||||
|
const { siteMode, validate } = core.middleware
|
||||||
|
const { requireFeature } = require('../../utils/shardVisibility')
|
||||||
|
|
||||||
|
const atlasRouter = express.Router()
|
||||||
|
|
||||||
|
// Facet names come from the shard's own files and are never validated against a
|
||||||
|
// list — nothing in the codebase names a facet (§6.1 R2). Only the length is
|
||||||
|
// bounded, and the query matches exactly, so an unknown name returns an empty
|
||||||
|
// result rather than an error.
|
||||||
|
const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 })
|
||||||
|
|
||||||
|
atlasRouter.get(
|
||||||
|
'/creatures',
|
||||||
|
requireFeature('atlas'),
|
||||||
|
// #swagger.tags = ['Public · Atlas']
|
||||||
|
// #swagger.summary = 'Search the bestiary (paginated)'
|
||||||
|
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
|
||||||
|
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
|
||||||
|
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||||
|
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
|
facetParam,
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||||
|
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||||
|
validate,
|
||||||
|
siteMode,
|
||||||
|
atlas.getCreatures,
|
||||||
|
)
|
||||||
|
atlasRouter.get(
|
||||||
|
'/creatures/:slug',
|
||||||
|
requireFeature('atlas'),
|
||||||
|
// #swagger.tags = ['Public · Atlas']
|
||||||
|
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
|
||||||
|
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
||||||
|
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
||||||
|
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
|
||||||
|
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('slug').isString().isLength({ min: 1, max: 120 }),
|
||||||
|
facetParam,
|
||||||
|
query('points').optional().isInt({ min: 1, max: 1000 }),
|
||||||
|
validate,
|
||||||
|
siteMode,
|
||||||
|
atlas.getCreature,
|
||||||
|
)
|
||||||
|
atlasRouter.get(
|
||||||
|
'/regions',
|
||||||
|
requireFeature('atlas'),
|
||||||
|
// #swagger.tags = ['Public · Atlas']
|
||||||
|
// #swagger.summary = 'Named regions and their rectangles'
|
||||||
|
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
|
||||||
|
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||||
|
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
|
||||||
|
facetParam,
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
|
validate,
|
||||||
|
siteMode,
|
||||||
|
atlas.getRegions,
|
||||||
|
)
|
||||||
|
atlasRouter.get(
|
||||||
|
'/landmarks',
|
||||||
|
requireFeature('atlas'),
|
||||||
|
// #swagger.tags = ['Public · Atlas']
|
||||||
|
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
|
||||||
|
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
|
||||||
|
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||||
|
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
|
||||||
|
facetParam,
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
|
validate,
|
||||||
|
siteMode,
|
||||||
|
atlas.getLandmarks,
|
||||||
|
)
|
||||||
|
atlasRouter.get(
|
||||||
|
'/champions',
|
||||||
|
requireFeature('atlas'),
|
||||||
|
// #swagger.tags = ['Public · Atlas']
|
||||||
|
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
||||||
|
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
|
||||||
|
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
|
||||||
|
facetParam,
|
||||||
|
validate,
|
||||||
|
siteMode,
|
||||||
|
atlas.getChampions,
|
||||||
|
)
|
||||||
|
atlasRouter.get(
|
||||||
|
'/meta',
|
||||||
|
requireFeature('atlas'),
|
||||||
|
// #swagger.tags = ['Public · Atlas']
|
||||||
|
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
||||||
|
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
|
||||||
|
siteMode,
|
||||||
|
atlas.getMeta,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = atlasRouter
|
||||||
422
server/router/public/shard.controller.js
Normal file
422
server/router/public/shard.controller.js
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
// ── 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 shardMarket = require('../../model/shardMarket/shardMarket.model')
|
||||||
|
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const broadcast = require('../../utils/shardBroadcast')
|
||||||
|
const visibility = require('../../utils/shardVisibility')
|
||||||
|
|
||||||
|
const log = require('../../core').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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marketplace (Protocol 3.0 vendor.listing) ──────────────────────────────
|
||||||
|
//
|
||||||
|
// The shard-wide player-vendor index. Served entirely from our own tables — the
|
||||||
|
// sidecar is never touched on this path — so shops stay browsable while the shard
|
||||||
|
// is down, labelled with how stale they may be.
|
||||||
|
//
|
||||||
|
// The staleness label is not decoration. The shard sweeps vendors round-robin, so
|
||||||
|
// a shop can legitimately be a full cycle behind; a page that implied live prices
|
||||||
|
// would send people to a vendor whose item sold twenty minutes ago.
|
||||||
|
|
||||||
|
// The serial spelling the bridge uses everywhere: "0x" and hex. Constrained
|
||||||
|
// before it reaches the model, like SYSTEM_RE above.
|
||||||
|
const SERIAL_RE = /^0x[0-9A-Fa-f]{1,16}$/
|
||||||
|
|
||||||
|
const intParam = (value) => {
|
||||||
|
const n = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/market — search the index.
|
||||||
|
//
|
||||||
|
// Returns LISTINGS, not vendors: "who sells a vanquishing kryss and for how much"
|
||||||
|
// is the question, and a vendor-shaped result would make every caller flatten the
|
||||||
|
// shops back out.
|
||||||
|
async function getMarket(req, res) {
|
||||||
|
try {
|
||||||
|
const page = await shardMarket.search({
|
||||||
|
q: typeof req.query.q === 'string' ? req.query.q : '',
|
||||||
|
minPrice: intParam(req.query.minPrice),
|
||||||
|
maxPrice: intParam(req.query.maxPrice),
|
||||||
|
itemId: intParam(req.query.itemId),
|
||||||
|
map: typeof req.query.map === 'string' ? req.query.map : '',
|
||||||
|
region: typeof req.query.region === 'string' ? req.query.region : '',
|
||||||
|
sort: typeof req.query.sort === 'string' ? req.query.sort : 'price_asc',
|
||||||
|
limit: intParam(req.query.limit) ?? 50,
|
||||||
|
offset: intParam(req.query.offset) ?? 0,
|
||||||
|
})
|
||||||
|
return res.json(await visibility.project('market', page, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getMarket', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/market/meta — index size, staleness, and the filter options
|
||||||
|
// (which facets and regions actually hold vendors). Separate from the search so
|
||||||
|
// the page can build its filters without running a query it will throw away.
|
||||||
|
async function getMarketMeta(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await visibility.project('market', await shardMarket.meta(), req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getMarketMeta', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/market/vendors/:serial — one shop and its listings.
|
||||||
|
//
|
||||||
|
// 404 for a serial the index has never seen, which also covers a vendor that has
|
||||||
|
// since been dismissed or hidden: to an anonymous caller "no such shop" is the
|
||||||
|
// only honest answer, and distinguishing the two would leak that a vendor exists
|
||||||
|
// but was hidden.
|
||||||
|
async function getMarketVendor(req, res) {
|
||||||
|
const { serial } = req.params
|
||||||
|
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid vendor serial.' })
|
||||||
|
try {
|
||||||
|
const vendor = await shardMarket.getVendor(serial, {
|
||||||
|
limit: intParam(req.query.limit) ?? 250,
|
||||||
|
offset: intParam(req.query.offset) ?? 0,
|
||||||
|
})
|
||||||
|
if (!vendor) return res.status(404).json({ message: 'Unknown vendor.' })
|
||||||
|
return res.json(await visibility.project('market', vendor, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getMarketVendor', 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,
|
||||||
|
getPointsBoards,
|
||||||
|
getPointsBoard,
|
||||||
|
getMarket,
|
||||||
|
getMarketMeta,
|
||||||
|
getMarketVendor,
|
||||||
|
getFeatures,
|
||||||
|
stream,
|
||||||
|
}
|
||||||
255
server/router/public/shard.router.js
Normal file
255
server/router/public/shard.router.js
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
// Public · Shard — token-free, same-origin reads of the live shard. The
|
||||||
|
// status/feed/economy/idoc/champs/guilds/governors/presence/houses endpoints read
|
||||||
|
// the site's own ingested data; nothing here round-trips the sidecar per request.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/public/shard by public/index.js. Deliberately NOT site-mode
|
||||||
|
// gated — shard status is useful (and wanted) while the site itself is in
|
||||||
|
// maintenance.
|
||||||
|
//
|
||||||
|
// **GET /shard/stream stays anonymous.** It is consumed by logged-out browser
|
||||||
|
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
||||||
|
// Authorization header; adding requireAuth here blacks out the public live boards
|
||||||
|
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
||||||
|
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
|
||||||
|
// that per-frame filtering is the security boundary, not this file. /stream is
|
||||||
|
// deliberately NOT wrapped in requireFeature either: it spans every feature, and
|
||||||
|
// each frame is gated individually against the subscriber's rung.
|
||||||
|
//
|
||||||
|
// Every other route carries `requireFeature(<name>)` (utils/shardVisibility.js),
|
||||||
|
// which 404s when an admin has disabled the feature and 403s when the caller sits
|
||||||
|
// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so
|
||||||
|
// these gates are inert until an admin changes something.
|
||||||
|
|
||||||
|
const core = require('../../core')
|
||||||
|
|
||||||
|
const express = core.express
|
||||||
|
const { param, query } = core.validator
|
||||||
|
|
||||||
|
const shard = require('./shard.controller')
|
||||||
|
const { validate } = core.middleware
|
||||||
|
const { marketLimiter } = require('../rateLimits')
|
||||||
|
const { requireFeature } = require('../../utils/shardVisibility')
|
||||||
|
|
||||||
|
const shardRouter = express.Router()
|
||||||
|
|
||||||
|
shardRouter.get(
|
||||||
|
'/status',
|
||||||
|
requireFeature('status'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||||
|
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
||||||
|
shard.getStatus,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/feed',
|
||||||
|
requireFeature('activity'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
||||||
|
// #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.'
|
||||||
|
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' }
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||||
|
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||||
|
validate,
|
||||||
|
shard.getFeed,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/economy',
|
||||||
|
requireFeature('status'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||||
|
validate,
|
||||||
|
shard.getEconomy,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/online',
|
||||||
|
requireFeature('presence'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
||||||
|
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||||
|
shard.getOnline,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/idoc',
|
||||||
|
requireFeature('houses'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
||||||
|
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
|
||||||
|
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
|
shard.getIdoc,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/champs',
|
||||||
|
requireFeature('champs'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
||||||
|
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
shard.getChamps,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/guilds',
|
||||||
|
requireFeature('guilds'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
||||||
|
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
shard.getGuilds,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/governors',
|
||||||
|
requireFeature('governors'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
||||||
|
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
shard.getGovernors,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/governors/:city/history',
|
||||||
|
requireFeature('governors'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Governor term history for a city'
|
||||||
|
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
param('city').isString().isLength({ min: 1, max: 40 }),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||||
|
validate,
|
||||||
|
shard.getGovernorHistory,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/presence',
|
||||||
|
requireFeature('presence'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
||||||
|
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
shard.getPresence,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/houses',
|
||||||
|
requireFeature('houses'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
||||||
|
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
||||||
|
/* #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(
|
||||||
|
'/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,
|
||||||
|
)
|
||||||
|
// ── Marketplace ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Rate-limited, unlike every other route in this file. These are the first
|
||||||
|
// genuinely expensive PUBLIC reads on the site — a LIKE scan plus a COUNT over
|
||||||
|
// what is typically the largest shard_* table, reachable with no session.
|
||||||
|
shardRouter.get(
|
||||||
|
'/market',
|
||||||
|
requireFeature('market'),
|
||||||
|
marketLimiter,
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Search the player-vendor marketplace'
|
||||||
|
// #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
|
||||||
|
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' }
|
||||||
|
// #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' }
|
||||||
|
// #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' }
|
||||||
|
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' }
|
||||||
|
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' }
|
||||||
|
// #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' }
|
||||||
|
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' }
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||||
|
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
|
query('minPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }),
|
||||||
|
query('maxPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }),
|
||||||
|
query('itemId').optional({ values: 'falsy' }).isInt({ min: 0, max: 65535 }),
|
||||||
|
query('map').optional({ values: 'falsy' }).isString().isLength({ max: 40 }),
|
||||||
|
query('region').optional({ values: 'falsy' }).isString().isLength({ max: 80 }),
|
||||||
|
query('sort').optional({ values: 'falsy' }).isIn(['price_asc', 'price_desc', 'recent']),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||||
|
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||||
|
validate,
|
||||||
|
shard.getMarket,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/market/meta',
|
||||||
|
requireFeature('market'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Marketplace size, staleness and filter options'
|
||||||
|
// #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */
|
||||||
|
shard.getMarketMeta,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/market/vendors/:serial',
|
||||||
|
requireFeature('market'),
|
||||||
|
marketLimiter,
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'One player vendor and everything it is selling'
|
||||||
|
// #swagger.description = 'A single shop by its vendor serial, with its listings. `truncated` (and `total` exceeding `count`) means the shop holds more than the shard publishes per frame — a commodity reseller with thousands of stacks is a real thing, and the page says so rather than presenting a partial shop as complete. Returns 404 for a serial the index has never seen, which also covers a vendor since dismissed or hidden.'
|
||||||
|
/* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' }
|
||||||
|
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Malformed vendor serial' } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
|
||||||
|
param('serial').isString().isLength({ max: 20 }),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||||
|
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||||
|
validate,
|
||||||
|
shard.getMarketVendor,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/features',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
|
||||||
|
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
|
||||||
|
shard.getFeatures,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/stream',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
||||||
|
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
|
||||||
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
|
shard.stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = shardRouter
|
||||||
46
server/router/rateLimits.js
Normal file
46
server/router/rateLimits.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// This module's own rate-limit policy, built on core's plumbing.
|
||||||
|
//
|
||||||
|
// `ctx.middleware.rateLimit` is core's `makeLimiter` (MODULE_API.md §2.3, API
|
||||||
|
// 1.1.0): the module states the window, the cap and the message, and core
|
||||||
|
// supplies the `express-rate-limit` instance, its store and the logging that
|
||||||
|
// records a breach. That division is the point. The policy is the module's —
|
||||||
|
// only the module knows what its endpoints cost — but there is one limiter
|
||||||
|
// library in the process and one place a breach is written down. A module that
|
||||||
|
// resolved `express-rate-limit` for itself would get a second store, and a limit
|
||||||
|
// enforced by two independent counters is not the limit either of them states.
|
||||||
|
//
|
||||||
|
// Built lazily, for the reason `core.js` explains: `core.middleware` resolves
|
||||||
|
// `ctx`, so touching it at require time would run before `register()`. The
|
||||||
|
// routers ask for these while they are being built, which is inside
|
||||||
|
// `register()`, and the result is memoised so a limiter is created once and the
|
||||||
|
// counter is not reset by a second call.
|
||||||
|
|
||||||
|
const core = require('../core')
|
||||||
|
|
||||||
|
let limiters = null
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
if (limiters) return limiters
|
||||||
|
limiters = {
|
||||||
|
// The player-vendor market search. The first genuinely expensive PUBLIC
|
||||||
|
// endpoint on the site: every call is a LIKE scan plus a COUNT over the
|
||||||
|
// listings table, which on a large shard is the biggest table there is, and
|
||||||
|
// it is anonymous by default. Generous for a human browsing shops (a typed
|
||||||
|
// search is debounced to one request, and paging is a click), tight enough
|
||||||
|
// that it cannot be used as a cheap way to load the database.
|
||||||
|
//
|
||||||
|
// This lived in core's `middleware/rateLimit.js` and is UO policy, so it
|
||||||
|
// came here with the route it guards.
|
||||||
|
marketLimiter: core.middleware.rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
label: 'market',
|
||||||
|
message: 'Too many searches. Please slow down.',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return limiters
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
get marketLimiter() { return build().marketLimiter },
|
||||||
|
}
|
||||||
@@ -45,11 +45,18 @@ const { isBuiltin } = require('module')
|
|||||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||||
|
|
||||||
// Dependencies this half is allowed to resolve for itself. Empty, and that is
|
// Packages the SHIPPED half may resolve for itself: this package's declared
|
||||||
// the design: everything the server half needs comes from `ctx` (§2.3). A new
|
// `dependencies`, and nothing else. Read from package.json rather than listed
|
||||||
// entry here is a real decision — it becomes a package an operator's install
|
// here, so adding one is a visible, reviewable edit to the manifest that also
|
||||||
// has to carry — so it should be argued for in a PR, not added in passing.
|
// changes what CI installs and what the release tarball carries.
|
||||||
const ALLOWED_PACKAGES = new Set([])
|
//
|
||||||
|
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
|
||||||
|
// release tarball carries `server/node_modules` because an operator never builds
|
||||||
|
// — so every entry is weight in the artifact and a package the operator's
|
||||||
|
// deployment now runs. Anything core already owns must come from `ctx` instead:
|
||||||
|
// a second express is a second Router prototype, a second express-rate-limit is
|
||||||
|
// a second store, and a limit enforced by two independent counters is not the
|
||||||
|
// limit either of them states.
|
||||||
|
|
||||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
||||||
|
|
||||||
@@ -58,9 +65,9 @@ const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
|||||||
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
|
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
|
||||||
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
||||||
|
|
||||||
const devDependencies = new Set(
|
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
|
||||||
Object.keys(JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).devDependencies || {}),
|
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
|
||||||
)
|
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
|
||||||
|
|
||||||
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
||||||
// need a dependency, and a specifier this pattern misses is a specifier written
|
// need a dependency, and a specifier this pattern misses is a specifier written
|
||||||
@@ -135,7 +142,7 @@ function* walk(dir) {
|
|||||||
* has never been shown to fail is a check nobody knows the state of — and this
|
* has never been shown to fail is a check nobody knows the state of — and this
|
||||||
* one guards the acceptance criterion for the whole contract.
|
* one guards the acceptance criterion for the whole contract.
|
||||||
*/
|
*/
|
||||||
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDependencies } = {}) {
|
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
|
||||||
const violations = []
|
const violations = []
|
||||||
for (const file of walk(root)) {
|
for (const file of walk(root)) {
|
||||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||||
@@ -151,7 +158,7 @@ function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, dev = devDe
|
|||||||
const pkg = specifier.startsWith('@')
|
const pkg = specifier.startsWith('@')
|
||||||
? specifier.split('/').slice(0, 2).join('/')
|
? specifier.split('/').slice(0, 2).join('/')
|
||||||
: specifier.split('/')[0]
|
: specifier.split('/')[0]
|
||||||
const allowed = ALLOWED_PACKAGES.has(pkg) || (!shipped(file) && dev.has(pkg))
|
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
|
||||||
// The `node:` prefix can only ever name a builtin, so it never reaches
|
// The `node:` prefix can only ever name a builtin, so it never reaches
|
||||||
// node_modules and is safe whatever this Node version enumerates.
|
// node_modules and is safe whatever this Node version enumerates.
|
||||||
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
||||||
|
|||||||
127
server/scripts/importSpawnAtlas.js
Normal file
127
server/scripts/importSpawnAtlas.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
//
|
||||||
|
// Refresh the spawn atlas from a ServUO tree, from the command line.
|
||||||
|
//
|
||||||
|
// npm run atlas:import # use the configured path
|
||||||
|
// npm run atlas:import -- --servuo <path> # override it for this run
|
||||||
|
// npm run atlas:import -- --force # reimport even if unchanged
|
||||||
|
// npm run atlas:import -- --approve # apply a staged refresh
|
||||||
|
// npm run atlas:import -- --status # report without changing anything
|
||||||
|
//
|
||||||
|
// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so
|
||||||
|
// this is for operators who want to apply a map change without a restart, and
|
||||||
|
// for approving a refresh that was staged because it would remove a facet.
|
||||||
|
//
|
||||||
|
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
|
||||||
|
// is argument parsing and output formatting.
|
||||||
|
|
||||||
|
const db = () => require('../src/utils/db')
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const flag = argv[i]
|
||||||
|
if (flag === '--servuo') args.servuo = argv[++i]
|
||||||
|
else if (flag === '--force') args.force = true
|
||||||
|
else if (flag === '--approve') args.approve = true
|
||||||
|
else if (flag === '--reject') args.reject = true
|
||||||
|
else if (flag === '--status') args.status = true
|
||||||
|
else if (flag === '--help' || flag === '-h') args.help = true
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `
|
||||||
|
Refresh the spawn atlas from a ServUO tree.
|
||||||
|
|
||||||
|
node scripts/importSpawnAtlas.js [options]
|
||||||
|
|
||||||
|
--servuo <path> Use this tree for this run instead of the configured path.
|
||||||
|
--force Reimport even when the source files are unchanged.
|
||||||
|
--approve Apply a refresh that was staged for removing a facet.
|
||||||
|
--reject Keep the current atlas and dismiss the staged refresh.
|
||||||
|
--status Report atlas and source state; change nothing.
|
||||||
|
|
||||||
|
With no options this imports only if the tree differs from what is loaded.
|
||||||
|
`
|
||||||
|
|
||||||
|
function describe(result) {
|
||||||
|
switch (result.status) {
|
||||||
|
case 'skipped':
|
||||||
|
return (
|
||||||
|
'No ServUO path configured — nothing to import.\n' +
|
||||||
|
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
||||||
|
)
|
||||||
|
case 'unavailable':
|
||||||
|
return `ServUO tree unavailable: ${result.reason}\n`
|
||||||
|
case 'unchanged':
|
||||||
|
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
||||||
|
case 'needsReview': {
|
||||||
|
return (
|
||||||
|
'Refresh NOT applied — it would remove ' +
|
||||||
|
`${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` +
|
||||||
|
'This is what a half-copied or mid-update tree looks like, so it has been\n' +
|
||||||
|
'staged for review. The current atlas is unchanged.\n' +
|
||||||
|
'Apply it with --approve, or dismiss it with --reject.\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'imported': {
|
||||||
|
const c = result.counts
|
||||||
|
const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : ''
|
||||||
|
const removed = result.removedFacets?.length
|
||||||
|
? ` Removed facets: ${result.removedFacets.join(', ')}.`
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
`Atlas imported: ${c.points} points, ${c.creatures} creatures, ` +
|
||||||
|
`${c.pointTypes} point/type rows, ${c.regions} regions, ` +
|
||||||
|
`${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'failed':
|
||||||
|
return `Atlas refresh failed: ${result.reason}\n`
|
||||||
|
default:
|
||||||
|
return `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (args.help) {
|
||||||
|
process.stdout.write(USAGE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||||
|
|
||||||
|
// `--servuo` is a per-run override and deliberately does NOT persist to the
|
||||||
|
// configured path; changing where the atlas permanently reads from is an
|
||||||
|
// admin action, not a side effect of a one-off import.
|
||||||
|
const override = { path: args.servuo ?? '' }
|
||||||
|
|
||||||
|
if (args.status) {
|
||||||
|
process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (args.reject) {
|
||||||
|
process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = args.approve
|
||||||
|
? await shardAtlas.approvePending(override)
|
||||||
|
: await shardAtlas.refresh({ ...override, force: Boolean(args.force) })
|
||||||
|
|
||||||
|
process.stdout.write(describe(result))
|
||||||
|
if (result.status === 'failed') process.exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(() => db().close())
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { describe, parseArgs }
|
||||||
@@ -28,6 +28,10 @@ function fakeLog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeCtx(overrides = {}) {
|
function fakeCtx(overrides = {}) {
|
||||||
|
// `freeze: false` is for _setup.js, which installs one process-wide ctx a test
|
||||||
|
// may adjust. Core always freezes; the unfrozen variant is a test seam and
|
||||||
|
// never a claim about what a module is handed in production.
|
||||||
|
const { freeze = true, ...rest } = overrides
|
||||||
const logs = []
|
const logs = []
|
||||||
const ctx = {
|
const ctx = {
|
||||||
moduleId: 'uo',
|
moduleId: 'uo',
|
||||||
@@ -50,10 +54,19 @@ function fakeCtx(overrides = {}) {
|
|||||||
siteMode: (req, res, next) => next(),
|
siteMode: (req, res, next) => next(),
|
||||||
validate: (req, res, next) => next(),
|
validate: (req, res, next) => next(),
|
||||||
noindex: (req, res, next) => next(),
|
noindex: (req, res, next) => next(),
|
||||||
|
// API 1.1.0. The factory returns a pass-through rather than a real
|
||||||
|
// limiter: a test that tripped a rate limit would be a test whose result
|
||||||
|
// depended on how many times the suite had run.
|
||||||
|
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
|
||||||
|
accountChangeLimiter: (req, res, next) => next(),
|
||||||
},
|
},
|
||||||
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
|
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
|
||||||
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
|
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
|
||||||
...overrides,
|
// The three §2.3 members API 1.1.0 added for this extraction.
|
||||||
|
activity: { log: spy(Promise.resolve()) },
|
||||||
|
users: { getById: spy(Promise.resolve(null)) },
|
||||||
|
site: { baseUrl: 'http://localhost:5173' },
|
||||||
|
...rest,
|
||||||
}
|
}
|
||||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||||
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
||||||
@@ -62,6 +75,7 @@ function fakeCtx(overrides = {}) {
|
|||||||
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
|
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
|
||||||
// a test put there.
|
// a test put there.
|
||||||
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||||
|
if (!freeze) return ctx
|
||||||
for (const value of Object.values(ctx)) {
|
for (const value of Object.values(ctx)) {
|
||||||
if (value && typeof value === 'object') Object.freeze(value)
|
if (value && typeof value === 'object') Object.freeze(value)
|
||||||
}
|
}
|
||||||
|
|||||||
32
server/test/_helper.js
Normal file
32
server/test/_helper.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Test helper: start a throwaway Express app on an ephemeral port and return its
|
||||||
|
// base URL + a close(). Uses the built-in fetch (Node 18+) so tests need no
|
||||||
|
// extra HTTP dependency. Tests here exercise middleware in isolation and do NOT
|
||||||
|
// touch the database.
|
||||||
|
// The module takes express off ctx in shipped code; a test may resolve it
|
||||||
|
// directly, because test/ never runs inside core's process (checkImports.js
|
||||||
|
// allows devDependencies there). Same express either way — this repo pins the
|
||||||
|
// version core declares.
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
async function startApp(configure) {
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json())
|
||||||
|
configure(app)
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const s = app.listen(0, '127.0.0.1', () => resolve(s))
|
||||||
|
})
|
||||||
|
const { port } = server.address()
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
// `server.close()` stops accepting and waits for open connections to end on
|
||||||
|
// their own — and node's global fetch keeps its sockets alive, so nothing
|
||||||
|
// ever ends them. The listener then outlives the test that made it, which
|
||||||
|
// used to be invisible because the pool held the process open anyway.
|
||||||
|
close: () => new Promise((resolve) => {
|
||||||
|
server.closeAllConnections()
|
||||||
|
server.close(resolve)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startApp }
|
||||||
27
server/test/_setup.js
Normal file
27
server/test/_setup.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Initialise `core` once, before any test file is required.
|
||||||
|
//
|
||||||
|
// Loaded via `node --test --require ./test/_setup.js`, the same arrangement
|
||||||
|
// core's own suite uses. It exists because of the one structural difference
|
||||||
|
// between testing this module and testing the code when it lived in core:
|
||||||
|
// nothing here can be stubbed by monkey-patching a core module, because there
|
||||||
|
// are no core modules to patch. `require('../utils/db')` does not exist. What a
|
||||||
|
// test controls instead is the `ctx` core would have handed over — which is a
|
||||||
|
// better seam anyway, since it is exactly the surface the contract promises and
|
||||||
|
// nothing wider.
|
||||||
|
//
|
||||||
|
// The ctx installed here is deliberately NOT frozen. Core freezes what it hands
|
||||||
|
// a module, and `entry.test.js` asserts the module behaves against a frozen one;
|
||||||
|
// but a test that needs `settings.get` to return a particular value has to be
|
||||||
|
// able to say so, and a frozen ctx would mean re-initialising core per test. The
|
||||||
|
// mutable copy is a test seam, not a claim about what core does.
|
||||||
|
|
||||||
|
const core = require('../core')
|
||||||
|
const { fakeCtx } = require('./_fakes')
|
||||||
|
|
||||||
|
const ctx = fakeCtx({ freeze: false })
|
||||||
|
core.init(ctx)
|
||||||
|
|
||||||
|
// Exposed so a test can reach the same object it is running against, e.g.
|
||||||
|
// `testCtx.settings.get = async () => '/srv/uo'`. There is one ctx per process,
|
||||||
|
// as there is in core, so a test that changes a member should put it back.
|
||||||
|
module.exports = { ctx }
|
||||||
154
server/test/adminUserShard.test.js
Normal file
154
server/test/adminUserShard.test.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||||
|
// so any stray query fails fast instead of hanging the runner. These tests stub
|
||||||
|
// every model method the controller touches, so the DB is never actually hit.
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const ctrl = require('../router/admin/usersShard.controller')
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
const users = ctx.users
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
const shardEvents = require('../model/shardEvents/shardEvents.model')
|
||||||
|
const { salesForAccounts } = require('../utils/shardSales')
|
||||||
|
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save/restore the originals so each test's monkeypatches don't leak.
|
||||||
|
const originals = {
|
||||||
|
getById: users.getById,
|
||||||
|
listForUser: shardLinks.listForUser,
|
||||||
|
listHousesForAccounts: shardState.listHousesForAccounts,
|
||||||
|
listOnlineForAccounts: shardState.listOnlineForAccounts,
|
||||||
|
eventsList: shardEvents.list,
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
users.getById = originals.getById
|
||||||
|
shardLinks.listForUser = originals.listForUser
|
||||||
|
shardState.listHousesForAccounts = originals.listHousesForAccounts
|
||||||
|
shardState.listOnlineForAccounts = originals.listOnlineForAccounts
|
||||||
|
shardEvents.list = originals.eventsList
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── salesForAccounts util ──────────────────────────────────────────────────
|
||||||
|
test('salesForAccounts returns [] for an empty account set without hitting the log', async () => {
|
||||||
|
let called = false
|
||||||
|
shardEvents.list = async () => {
|
||||||
|
called = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
assert.deepEqual(await salesForAccounts([]), [])
|
||||||
|
assert.equal(called, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('salesForAccounts keeps only sales owned by the given accounts, newest 50', async () => {
|
||||||
|
const events = []
|
||||||
|
// 60 sales owned by "mine", plus some owned by "other".
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
events.push({ t: i, payload: { ownerAcct: 'mine', itemType: 'sword', amount: 1, price: 10, commission: 1 } })
|
||||||
|
}
|
||||||
|
events.push({ t: 999, payload: { ownerAcct: 'other', itemType: 'shield', amount: 1, price: 5 } })
|
||||||
|
shardEvents.list = async () => events
|
||||||
|
|
||||||
|
const rows = await salesForAccounts(['mine'])
|
||||||
|
assert.equal(rows.length, 50) // capped
|
||||||
|
assert.ok(rows.every((r) => r.ownerAcct === 'mine')) // never leaks "other"
|
||||||
|
assert.deepEqual(Object.keys(rows[0]).sort(), ['amount', 'commission', 'itemType', 'ownerAcct', 'price', 't'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Controller: unknown user → 404 ─────────────────────────────────────────
|
||||||
|
for (const handler of ['listAccounts', 'getSales', 'getHouses', 'getOnline']) {
|
||||||
|
test(`${handler} returns 404 when the user does not exist`, async () => {
|
||||||
|
users.getById = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl[handler]({ params: { id: '404' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controller: scoping to the user's accounts ─────────────────────────────
|
||||||
|
test('listAccounts returns the user’s linked accounts', async () => {
|
||||||
|
users.getById = async () => ({ id: 7, username: 'bob', role: 'player' })
|
||||||
|
shardLinks.listForUser = async (id) => {
|
||||||
|
assert.equal(id, 7)
|
||||||
|
return [{ account: 'acctA' }, { account: 'acctB' }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.listAccounts({ params: { id: '7' } }, res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.deepEqual(res.body, [{ account: 'acctA' }, { account: 'acctB' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getHouses passes exactly the user’s accounts to the model', async () => {
|
||||||
|
users.getById = async () => ({ id: 7 })
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'acctA' }, { account: 'acctB' }]
|
||||||
|
let received = null
|
||||||
|
shardState.listHousesForAccounts = async (accounts) => {
|
||||||
|
received = accounts
|
||||||
|
return [{ serial: '0x1', isIdoc: true }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getHouses({ params: { id: '7' } }, res)
|
||||||
|
assert.deepEqual(received, ['acctA', 'acctB'])
|
||||||
|
assert.deepEqual(res.body, [{ serial: '0x1', isIdoc: true }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getOnline passes exactly the user’s accounts to the model', async () => {
|
||||||
|
users.getById = async () => ({ id: 7 })
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'acctA' }]
|
||||||
|
let received = null
|
||||||
|
shardState.listOnlineForAccounts = async (accounts) => {
|
||||||
|
received = accounts
|
||||||
|
return [{ serial: '0x2', name: 'Zoe' }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getOnline({ params: { id: '7' } }, res)
|
||||||
|
assert.deepEqual(received, ['acctA'])
|
||||||
|
assert.deepEqual(res.body, [{ serial: '0x2', name: 'Zoe' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a user with no linked accounts yields empty sales/houses/online', async () => {
|
||||||
|
users.getById = async () => ({ id: 7 })
|
||||||
|
shardLinks.listForUser = async () => []
|
||||||
|
shardState.listHousesForAccounts = async (a) => (a.length ? [{}] : [])
|
||||||
|
shardState.listOnlineForAccounts = async (a) => (a.length ? [{}] : [])
|
||||||
|
shardEvents.list = async () => [{ payload: { ownerAcct: 'someoneElse' } }]
|
||||||
|
|
||||||
|
const sales = mockRes()
|
||||||
|
const houses = mockRes()
|
||||||
|
const online = mockRes()
|
||||||
|
await ctrl.getSales({ params: { id: '7' } }, sales)
|
||||||
|
await ctrl.getHouses({ params: { id: '7' } }, houses)
|
||||||
|
await ctrl.getOnline({ params: { id: '7' } }, online)
|
||||||
|
|
||||||
|
assert.deepEqual(sales.body, [])
|
||||||
|
assert.deepEqual(houses.body, [])
|
||||||
|
assert.deepEqual(online.body, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// getUser is NOT here any more: reading a user is core semantics that had ended
|
||||||
|
// up in this controller by proximity, and PR 4 moved it back to
|
||||||
|
// admin.controller.js behind the extension slot (MODULE_SYSTEM.md §1.9). It is
|
||||||
|
// covered by test/adminUsers.test.js.
|
||||||
242
server/test/atlasController.test.js
Normal file
242
server/test/atlasController.test.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Point the DB at a closed port BEFORE requiring the controllers (their models
|
||||||
|
// build the pool). Every model call is monkeypatched, so no query runs;
|
||||||
|
// db.close() at the end releases the pool so the process exits cleanly.
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// The spawn atlas API, public and admin. What is worth asserting here is not the
|
||||||
|
// SQL (that is the parser suite's job) but the contracts the two surfaces make:
|
||||||
|
//
|
||||||
|
// • the public reads project through the visibility framework — v3.md §3.6.1's
|
||||||
|
// standing rule is that a read path returning shard data and not calling
|
||||||
|
// projectFeature is a bug, and `atlas` declaring no sensitive fields TODAY is
|
||||||
|
// exactly why the call has to be there before one does;
|
||||||
|
// • the public /meta route reports the game world only, never the operator's
|
||||||
|
// filesystem — the ServUO path, the per-file hashes and any pending refresh
|
||||||
|
// stay on the admin route;
|
||||||
|
// • a missing creature is a 404, not an empty 200;
|
||||||
|
// • an unreadable ServUO tree is a 200 carrying `status: 'unavailable'`, NOT a
|
||||||
|
// 500. The refresh contract reports outcomes rather than throwing (so boot is
|
||||||
|
// never blocked by a bad tree), and the admin needs to be told what is wrong
|
||||||
|
// with their path;
|
||||||
|
// • a model failure degrades to a 500 rather than a thrown/uncaught error.
|
||||||
|
const pub = require('../router/public/atlas.controller')
|
||||||
|
const admin = require('../router/admin/shardAtlas.controller')
|
||||||
|
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
const activity = ctx.activity
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
|
||||||
|
|
||||||
|
// Stub the visibility MODEL rather than the util's exports: project() calls the
|
||||||
|
// module-internal getConfig, which an exports-level stub would not intercept — it
|
||||||
|
// would hit the closed DB port and cost a ~10s pool timeout per test before
|
||||||
|
// falling back to these same defaults.
|
||||||
|
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
|
||||||
|
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
|
||||||
|
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
|
||||||
|
|
||||||
|
// The admin controller logs every action; keep it off the DB.
|
||||||
|
activity.log = async () => {}
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
searchCreatures: atlas.searchCreatures,
|
||||||
|
getCreature: atlas.getCreature,
|
||||||
|
listRegions: atlas.listRegions,
|
||||||
|
listLandmarks: atlas.listLandmarks,
|
||||||
|
listChampions: atlas.listChampions,
|
||||||
|
publicMeta: atlas.publicMeta,
|
||||||
|
status: atlas.status,
|
||||||
|
refresh: atlas.refresh,
|
||||||
|
approvePending: atlas.approvePending,
|
||||||
|
rejectPending: atlas.rejectPending,
|
||||||
|
setServuoPath: atlas.setServuoPath,
|
||||||
|
}
|
||||||
|
afterEach(() => Object.assign(atlas, originals))
|
||||||
|
|
||||||
|
// ── Public reads ────────────────────────────────────────────────────────
|
||||||
|
test('getCreatures passes the search through and returns the page shape', async () => {
|
||||||
|
let seen = null
|
||||||
|
atlas.searchCreatures = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return { total: 1, limit: 50, offset: 0, creatures: [{ slug: 'lizardman', name: 'Lizardman' }] }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await pub.getCreatures({ query: { q: ' lizard ', facet: 'Felucca', limit: '10', offset: '20' } }, res)
|
||||||
|
assert.deepEqual(seen, { q: 'lizard', facet: 'Felucca', limit: 10, offset: 20 })
|
||||||
|
assert.equal(res.body.total, 1)
|
||||||
|
assert.equal(res.body.creatures[0].slug, 'lizardman')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getCreatures falls back to the documented defaults when nothing is passed', async () => {
|
||||||
|
let seen = null
|
||||||
|
atlas.searchCreatures = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return { total: 0, limit: 50, offset: 0, creatures: [] }
|
||||||
|
}
|
||||||
|
await pub.getCreatures({ query: {} }, mockRes())
|
||||||
|
assert.deepEqual(seen, { q: '', facet: '', limit: 50, offset: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown creature is a 404, not an empty 200', async () => {
|
||||||
|
atlas.getCreature = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await pub.getCreature({ params: { slug: 'nosuchthing' }, query: {} }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getCreature returns places and spawners, and `points` stays the COUNT', async () => {
|
||||||
|
atlas.getCreature = async () => ({
|
||||||
|
slug: 'lizardman',
|
||||||
|
name: 'Lizardman',
|
||||||
|
total: 214,
|
||||||
|
points: 62,
|
||||||
|
places: [{ facet: 'Trammel', label: 'Shrines', spawners: 7, maxAlive: 21 }],
|
||||||
|
spawners: [{ id: 1, facet: 'Trammel', label: 'Shrines', x: 1, y: 2 }],
|
||||||
|
spawnersTruncated: false,
|
||||||
|
alsoHere: [],
|
||||||
|
})
|
||||||
|
const res = mockRes()
|
||||||
|
await pub.getCreature({ params: { slug: 'lizardman' }, query: {} }, res)
|
||||||
|
// The list route uses `points` as a number; the detail route must not quietly
|
||||||
|
// turn the same key into an array.
|
||||||
|
assert.equal(typeof res.body.points, 'number')
|
||||||
|
assert.ok(Array.isArray(res.body.spawners))
|
||||||
|
assert.equal(res.body.places[0].label, 'Shrines')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The projection rule (§3.6.1) ────────────────────────────────────────
|
||||||
|
test('public reads run through projectFeature, so a locked field can never survive', async () => {
|
||||||
|
// `atlas` declares no sensitive fields, so nothing here is stripped by a
|
||||||
|
// FEATURE rule. acct/webId are stripped anyway — they are locked by meaning,
|
||||||
|
// for every feature, and this is what proves the read path projects at all.
|
||||||
|
atlas.searchCreatures = async () => ({
|
||||||
|
total: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
creatures: [{ slug: 'lizardman', name: 'Lizardman', acct: 'someacct', ownerWebId: 7 }],
|
||||||
|
})
|
||||||
|
const res = mockRes()
|
||||||
|
await pub.getCreatures({ query: {}, viewerLevel: 'anonymous' }, res)
|
||||||
|
const row = res.body.creatures[0]
|
||||||
|
assert.equal(row.name, 'Lizardman')
|
||||||
|
assert.ok(!('acct' in row), 'acct must never reach an anonymous caller')
|
||||||
|
assert.ok(!('ownerWebId' in row), 'a flattened webId spelling is locked too')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getMeta reports the game world only — never the operator’s filesystem', async () => {
|
||||||
|
// The model is what enforces this; the assertion documents the boundary so a
|
||||||
|
// future "just return status() here" shortcut fails loudly.
|
||||||
|
atlas.publicMeta = async () => ({
|
||||||
|
importedAt: '2026-07-28T00:00:00.000Z',
|
||||||
|
generatedAt: '2026-07-28T00:00:00.000Z',
|
||||||
|
counts: { points: 6455, creatures: 800 },
|
||||||
|
facets: ['Felucca', 'Trammel'],
|
||||||
|
})
|
||||||
|
const res = mockRes()
|
||||||
|
await pub.getMeta({ query: {} }, res)
|
||||||
|
assert.deepEqual(Object.keys(res.body).sort(), ['counts', 'facets', 'generatedAt', 'importedAt'])
|
||||||
|
assert.ok(!('path' in res.body))
|
||||||
|
assert.ok(!('pending' in res.body))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a model failure degrades to a 500 rather than throwing', async () => {
|
||||||
|
atlas.listChampions = async () => {
|
||||||
|
throw new Error('table is gone')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await pub.getChampions({ query: {} }, res)
|
||||||
|
assert.equal(res.statusCode, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Admin ───────────────────────────────────────────────────────────────
|
||||||
|
test('an unreadable tree answers 200 with the reason, not a 500', async () => {
|
||||||
|
atlas.refresh = async () => ({ status: 'unavailable', reason: 'no Spawns directory', path: '/bad' })
|
||||||
|
const res = mockRes()
|
||||||
|
await admin.importAtlas({ body: {}, user: { id: 1 } }, res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.equal(res.body.status, 'unavailable')
|
||||||
|
assert.equal(res.body.reason, 'no Spawns directory')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('import passes `force` through and coerces it to a boolean', async () => {
|
||||||
|
let seen = null
|
||||||
|
atlas.refresh = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return { status: 'unchanged' }
|
||||||
|
}
|
||||||
|
await admin.importAtlas({ body: { force: true }, user: { id: 1 } }, mockRes())
|
||||||
|
assert.deepEqual(seen, { force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('approve applies a staged refresh (facet loss included)', async () => {
|
||||||
|
let called = false
|
||||||
|
atlas.approvePending = async () => {
|
||||||
|
called = true
|
||||||
|
return { status: 'imported', removedFacets: ['Malas'], counts: { points: 6162 } }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await admin.approve({ user: { id: 1 } }, res)
|
||||||
|
assert.ok(called)
|
||||||
|
assert.equal(res.body.status, 'imported')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejecting when nothing is staged is a 404', async () => {
|
||||||
|
atlas.rejectPending = async () => ({ status: 'none' })
|
||||||
|
const res = mockRes()
|
||||||
|
await admin.reject({ user: { id: 1 } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setPath trims, persists, and answers with fresh status — it does not import', async () => {
|
||||||
|
let saved = null
|
||||||
|
let imported = false
|
||||||
|
atlas.setServuoPath = async (value) => {
|
||||||
|
saved = value
|
||||||
|
}
|
||||||
|
atlas.refresh = async () => {
|
||||||
|
imported = true
|
||||||
|
return { status: 'imported' }
|
||||||
|
}
|
||||||
|
atlas.status = async () => ({ configured: true, path: '/srv/servuo', treeReadable: true })
|
||||||
|
const res = mockRes()
|
||||||
|
await admin.setPath({ body: { path: ' /srv/servuo ' }, user: { id: 3 } }, res)
|
||||||
|
assert.equal(saved, '/srv/servuo')
|
||||||
|
assert.equal(imported, false, 'changing the path must not reload the atlas as a side effect')
|
||||||
|
assert.equal(res.body.path, '/srv/servuo')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setPath accepts a blank path (clearing it turns the atlas off)', async () => {
|
||||||
|
let saved = 'unset'
|
||||||
|
atlas.setServuoPath = async (value) => {
|
||||||
|
saved = value
|
||||||
|
}
|
||||||
|
atlas.status = async () => ({ configured: false, path: '' })
|
||||||
|
const res = mockRes()
|
||||||
|
await admin.setPath({ body: {}, user: { id: 3 } }, res)
|
||||||
|
assert.equal(saved, '')
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
})
|
||||||
227
server/test/clilocParse.test.js
Normal file
227
server/test/clilocParse.test.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
ClilocFormatError,
|
||||||
|
parseCliloc,
|
||||||
|
parseClilocBinary,
|
||||||
|
parseClilocText,
|
||||||
|
isCompressedCliloc,
|
||||||
|
displayText,
|
||||||
|
isPlaceholderOnly,
|
||||||
|
} = require('../utils/clilocParse')
|
||||||
|
|
||||||
|
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
||||||
|
// where there is no UO client and no converted cliloc file. Every fixture below
|
||||||
|
// is built from the real layout, and the strings are verbatim entries from a
|
||||||
|
// real Cliloc.enu (123,490 entries) rather than invented ones.
|
||||||
|
|
||||||
|
// ── Fixture builders ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build a plain-format cliloc buffer: 6-byte header, then records. */
|
||||||
|
function buildBinary(entries, { header1 = 2, header2 = 1 } = {}) {
|
||||||
|
const parts = [Buffer.alloc(6)]
|
||||||
|
parts[0].writeInt32LE(header1, 0)
|
||||||
|
parts[0].writeUInt16LE(header2, 4)
|
||||||
|
for (const e of entries) {
|
||||||
|
const text = Buffer.from(e.text, 'utf8')
|
||||||
|
const head = Buffer.alloc(7)
|
||||||
|
head.writeInt32LE(e.number, 0)
|
||||||
|
head.writeUInt8(e.flag ?? 0, 4)
|
||||||
|
head.writeUInt16LE(text.length, 5)
|
||||||
|
parts.push(head, text)
|
||||||
|
}
|
||||||
|
return Buffer.concat(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Binary ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseClilocBinary: reads a plain-format table', () => {
|
||||||
|
const buf = buildBinary([
|
||||||
|
{ number: 1015012, text: 'Greater Heal' },
|
||||||
|
{ number: 1023721, text: 'quarter staff' },
|
||||||
|
{ number: 1025913, flag: 1, text: 'bonnet' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(parseClilocBinary(buf), [
|
||||||
|
{ number: 1015012, flag: 0, text: 'Greater Heal' },
|
||||||
|
{ number: 1023721, flag: 0, text: 'quarter staff' },
|
||||||
|
{ number: 1025913, flag: 1, text: 'bonnet' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocBinary: length is UNSIGNED 16-bit', () => {
|
||||||
|
// ServUO's own SDK reads this field into a signed short, which turns any
|
||||||
|
// string over 32 KB into a negative length. Real tables top out around 12 KB
|
||||||
|
// so nothing is broken today, but the field is written unsigned and reading it
|
||||||
|
// that way costs nothing.
|
||||||
|
const text = 'x'.repeat(40000)
|
||||||
|
const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text }]))
|
||||||
|
assert.equal(entry.text.length, 40000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocBinary: multi-byte UTF-8 survives (length is in BYTES)', () => {
|
||||||
|
const [entry] = parseClilocBinary(buildBinary([{ number: 1000000, text: 'Ilshenar — Ver Lor Reg' }]))
|
||||||
|
assert.equal(entry.text, 'Ilshenar — Ver Lor Reg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocBinary: a truncated record body throws rather than importing short', () => {
|
||||||
|
// The realistic corruption is a half-copied file. It must fail loudly: a
|
||||||
|
// silently short table renders as "some items named, some not", which is
|
||||||
|
// indistinguishable from having no table at all.
|
||||||
|
const buf = buildBinary([{ number: 1023721, text: 'quarter staff' }])
|
||||||
|
const truncated = buf.subarray(0, buf.length - 4)
|
||||||
|
assert.throws(() => parseClilocBinary(truncated), (err) => {
|
||||||
|
assert.ok(err instanceof ClilocFormatError)
|
||||||
|
assert.equal(err.code, 'TRUNCATED')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocBinary: a truncated record HEADER throws too', () => {
|
||||||
|
const buf = Buffer.concat([buildBinary([{ number: 1023721, text: 'quarter staff' }]), Buffer.alloc(3)])
|
||||||
|
assert.throws(() => parseClilocBinary(buf), (err) => err.code === 'TRUNCATED')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocBinary: an empty table (header only) is valid', () => {
|
||||||
|
assert.deepEqual(parseClilocBinary(buildBinary([])), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Compressed detection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('isCompressedCliloc: recognises the Mythic marker', () => {
|
||||||
|
// Every cliloc the client ships opens with a DWORD whose high byte is 0x8E.
|
||||||
|
// Real first bytes of Cliloc.enu (e8 79 67 8e) and Cliloc.deu (99 5d 26 8e).
|
||||||
|
assert.equal(isCompressedCliloc(Buffer.from([0xe8, 0x79, 0x67, 0x8e])), true)
|
||||||
|
assert.equal(isCompressedCliloc(Buffer.from([0x99, 0x5d, 0x26, 0x8e])), true)
|
||||||
|
assert.equal(isCompressedCliloc(buildBinary([])), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseCliloc: a compressed file is rejected by NAME, not parsed into nonsense', () => {
|
||||||
|
// This is the whole reason the marker check exists. Without it the plain
|
||||||
|
// parser reads compressed bytes as ~19k records of negative ids and 60 KB
|
||||||
|
// "strings" before dying somewhere in the middle — and the resulting error
|
||||||
|
// names truncation, which is the wrong problem to hand an operator.
|
||||||
|
const compressed = Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(64, 0x41)])
|
||||||
|
assert.throws(() => parseCliloc(compressed), (err) => {
|
||||||
|
assert.equal(err.code, 'COMPRESSED')
|
||||||
|
assert.match(err.message, /CLILOCS\.md/)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Text ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseClilocText: tab-delimited, skipping a header row', () => {
|
||||||
|
const entries = parseClilocText('number\ttext\n1023721\tquarter staff\n1015012\tGreater Heal\n')
|
||||||
|
assert.deepEqual(entries, [
|
||||||
|
{ number: 1023721, flag: 0, text: 'quarter staff' },
|
||||||
|
{ number: 1015012, flag: 0, text: 'Greater Heal' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: splits on the FIRST separator only', () => {
|
||||||
|
// Cliloc text is full of commas. Splitting on all of them would truncate every
|
||||||
|
// such entry at its first one.
|
||||||
|
const [entry] = parseClilocText('1044000,a scroll of magery, unfinished\n')
|
||||||
|
assert.equal(entry.text, 'a scroll of magery, unfinished')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: unwraps quoted CSV fields and doubled quotes', () => {
|
||||||
|
const [entry] = parseClilocText('1023721,"a ""quarter"" staff, plain"\n')
|
||||||
|
assert.equal(entry.text, 'a "quarter" staff, plain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: reads an optional flag column', () => {
|
||||||
|
const [entry] = parseClilocText('1025913\t1\tbonnet\n')
|
||||||
|
assert.deepEqual(entry, { number: 1025913, flag: 1, text: 'bonnet' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: text that is itself a number stays the text', () => {
|
||||||
|
// `number,text` where text is "100" is indistinguishable from `number,flag`
|
||||||
|
// with an empty text. Keeping it as the text is the safer miss — the other way
|
||||||
|
// silently deletes a real entry.
|
||||||
|
const [entry] = parseClilocText('1000000,100\n')
|
||||||
|
assert.equal(entry.text, '100')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: blank lines and # comments are ignored', () => {
|
||||||
|
const entries = parseClilocText('# exported by hand\n\n1023721\tquarter staff\n\n')
|
||||||
|
assert.equal(entries.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: a file with no entries is an error, not an empty table', () => {
|
||||||
|
assert.throws(() => parseClilocText('nothing here\nnor here\n'), (err) => err.code === 'EMPTY')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: an empty leading field is skipped, not imported as id 0', () => {
|
||||||
|
// `Number('')` is 0, not NaN, so a line that merely starts with a separator
|
||||||
|
// would otherwise become a bogus cliloc 0.
|
||||||
|
assert.throws(() => parseClilocText('\tstray text\n,another\n'), (err) => err.code === 'EMPTY')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseClilocText: keeps entries whose text is EMPTY', () => {
|
||||||
|
// About half of a real table is empty strings (unused ids). They must survive
|
||||||
|
// parsing — the import layer decides whether to store them, and both input
|
||||||
|
// formats have to agree on what the file contained.
|
||||||
|
const entries = parseClilocText('1005008\t\n1023721\tquarter staff\n')
|
||||||
|
assert.equal(entries.length, 2)
|
||||||
|
assert.deepEqual(entries[0], { number: 1005008, flag: 0, text: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Sniffing ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseCliloc: sniffs binary vs text from the header, not the extension', () => {
|
||||||
|
assert.equal(parseCliloc(buildBinary([{ number: 1023721, text: 'quarter staff' }]))[0].text, 'quarter staff')
|
||||||
|
assert.equal(parseCliloc(Buffer.from('1023721\tquarter staff\n'))[0].text, 'quarter staff')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseCliloc: a binary-looking header that is not 2/1 falls through to text', () => {
|
||||||
|
// The recoverable guess: a mis-sniffed text file says "no entries found",
|
||||||
|
// while a mis-sniffed binary yields plausible nonsense.
|
||||||
|
assert.throws(() => parseCliloc(Buffer.from([9, 0, 0, 0, 9, 0, 65, 66])), (err) => err.code === 'EMPTY')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Display ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('displayText: drops interpolated arguments we never receive', () => {
|
||||||
|
// The bridge sends a cliloc id, never the property packet that carries the
|
||||||
|
// arguments, so a name containing them has to be reduced to what is knowable.
|
||||||
|
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
|
||||||
|
assert.equal(displayText('~1_NAME~ the ~2_TITLE~'), 'the')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayText: a string that is nothing but arguments resolves to nothing', () => {
|
||||||
|
assert.equal(displayText('[~1_stuff~]'), '')
|
||||||
|
assert.equal(isPlaceholderOnly('[~1_stuff~]'), true)
|
||||||
|
assert.equal(isPlaceholderOnly('quarter staff'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayText: a trailing % is only stripped when a placeholder was removed', () => {
|
||||||
|
// "cold damage ~1_val~%" loses its % because that % was the unit belonging to
|
||||||
|
// the number we never had. A string that genuinely ends in one keeps it.
|
||||||
|
assert.equal(displayText('50%'), '50%')
|
||||||
|
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayText: ordinary names pass through untouched', () => {
|
||||||
|
assert.equal(displayText('quarter staff'), 'quarter staff')
|
||||||
|
assert.equal(displayText('a scroll of magery, unfinished'), 'a scroll of magery, unfinished')
|
||||||
|
assert.equal(displayText(' spiked collar '), 'spiked collar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayText: punctuation is only tidied when a placeholder was removed', () => {
|
||||||
|
// A shard's custom "Runic Gateway Sigil (v2)" came back as "(v2" while the
|
||||||
|
// bracket trim was unconditional. A string with no placeholder has no debris
|
||||||
|
// to clean, so it is left alone apart from whitespace.
|
||||||
|
assert.equal(displayText('Runic Gateway Sigil (v2)'), 'Runic Gateway Sigil (v2)')
|
||||||
|
assert.equal(displayText('scroll of power - greater'), 'scroll of power - greater')
|
||||||
|
assert.equal(displayText('[Companion] Great Dane'), '[Companion] Great Dane')
|
||||||
|
// …but the debris a placeholder leaves behind is still cleaned.
|
||||||
|
assert.equal(displayText('[~1_stuff~]'), '')
|
||||||
|
assert.equal(displayText('cold damage ~1_val~%'), 'cold damage')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayText: null and undefined are empty, not "null"', () => {
|
||||||
|
assert.equal(displayText(null), '')
|
||||||
|
assert.equal(displayText(undefined), '')
|
||||||
|
})
|
||||||
194
server/test/clilocSource.test.js
Normal file
194
server/test/clilocSource.test.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const {
|
||||||
|
ClilocSourceError,
|
||||||
|
CUSTOM_DIR,
|
||||||
|
resolveBase,
|
||||||
|
listCustom,
|
||||||
|
readSources,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
missingSources,
|
||||||
|
readCliloc,
|
||||||
|
} = require('../utils/clilocSource')
|
||||||
|
|
||||||
|
// The fs layer, exercised against real temp directories rather than mocks —
|
||||||
|
// the behaviours that matter here (which file wins, what a directory listing
|
||||||
|
// yields, what happens when one vanishes) are precisely the ones a mock would
|
||||||
|
// define away.
|
||||||
|
|
||||||
|
function tmpdir() {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-'))
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsv = (entries) => entries.map(([n, t]) => `${n}\t${t}`).join('\n') + '\n'
|
||||||
|
|
||||||
|
function write(dir, name, contents) {
|
||||||
|
const file = path.join(dir, name)
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||||
|
fs.writeFileSync(file, contents)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('resolveBase: a directory picks the most specific candidate', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
// A converted file sitting next to the client's own compressed one must win —
|
||||||
|
// otherwise pointing at a client folder finds the file that will be rejected.
|
||||||
|
write(dir, 'cliloc.enu', 'ignored')
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
|
||||||
|
assert.equal(path.basename(resolveBase(dir).base), 'clilocs.tsv')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveBase: a file path roots overlays at its DIRECTORY', () => {
|
||||||
|
// An operator who pointed at a file should not have to re-point at its folder
|
||||||
|
// just to add a custom/ directory beside it.
|
||||||
|
const dir = tmpdir()
|
||||||
|
const file = write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
assert.deepEqual(resolveBase(file), { root: dir, base: file })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveBase: a missing path and an empty path are different errors', () => {
|
||||||
|
assert.throws(() => resolveBase(''), (err) => err.code === 'NO_PATH')
|
||||||
|
assert.throws(() => resolveBase(path.join(tmpdir(), 'nope')), (err) => err.code === 'NOT_FOUND')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveBase: a directory with no cliloc file names what it looked for', () => {
|
||||||
|
assert.throws(() => resolveBase(tmpdir()), (err) => {
|
||||||
|
assert.ok(err instanceof ClilocSourceError)
|
||||||
|
assert.equal(err.code, 'NO_FILE')
|
||||||
|
assert.match(err.message, /clilocs\.tsv/)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Overlays ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('listCustom: no overlay directory is normal, not an error', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
assert.deepEqual(listCustom(dir), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listCustom: sorted, and only recognised extensions', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/b.tsv`, tsv([[2, 'b']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/a.csv`, tsv([[3, 'c']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/notes.md`, 'ignore me')
|
||||||
|
assert.deepEqual(listCustom(dir).map((f) => path.basename(f)), ['a.csv', 'b.tsv'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readSources: base first, then overlays, with root-relative labels', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
|
||||||
|
const { files } = readSources(dir)
|
||||||
|
// Forward-slashed so the same directory read on Windows and Linux fingerprints
|
||||||
|
// identically — otherwise every boot on one of them looks like a change.
|
||||||
|
assert.deepEqual(files.map((f) => [f.label, f.kind]), [
|
||||||
|
['clilocs.tsv', 'base'],
|
||||||
|
['custom/shard.tsv', 'custom'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Merging ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('readCliloc: an overlay ADDS ids the base never had', () => {
|
||||||
|
// The whole point: shards add items, and those carry cliloc ids no stock
|
||||||
|
// client table has.
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1180001, 'Runic Gateway Sigil']]))
|
||||||
|
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
|
||||||
|
assert.equal(byNumber.get(1023721), 'quarter staff')
|
||||||
|
assert.equal(byNumber.get(1180001), 'Runic Gateway Sigil')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readCliloc: an overlay OVERRIDES a stock id', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1023721, 'quarter staff']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[1023721, 'gnarled staff of testing']]))
|
||||||
|
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
|
||||||
|
assert.equal(byNumber.get(1023721), 'gnarled staff of testing')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readCliloc: later overlays beat earlier ones, deterministically', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[7, 'base']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/01-first.tsv`, tsv([[7, 'first']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/02-second.tsv`, tsv([[7, 'second']]))
|
||||||
|
const byNumber = new Map(readCliloc(dir).entries.map((e) => [e.number, e.text]))
|
||||||
|
assert.equal(byNumber.get(7), 'second')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readCliloc: reports what each source contributed', () => {
|
||||||
|
// An operator who adds an overlay wants to see it took effect; "overrode: 0"
|
||||||
|
// on a file meant to re-label stock items says it did not.
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a'], [2, 'b']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'B!'], [3, 'c']]))
|
||||||
|
const { source } = readCliloc(dir)
|
||||||
|
assert.deepEqual(source.sources, [
|
||||||
|
{ label: 'clilocs.tsv', kind: 'base', entries: 2, added: 2, overrode: 0 },
|
||||||
|
{ label: 'custom/shard.tsv', kind: 'custom', entries: 2, added: 1, overrode: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readCliloc: a malformed overlay names the file it came from', () => {
|
||||||
|
// "Which of my six overlay files is broken" is otherwise a guessing game.
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/broken.tsv`, 'no separators here\nnor here\n')
|
||||||
|
assert.throws(() => readCliloc(dir), (err) => {
|
||||||
|
assert.equal(err.code, 'EMPTY')
|
||||||
|
assert.match(err.message, /^custom\/broken\.tsv: /)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readCliloc: a compressed BASE is still rejected by name', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'cliloc.enu', Buffer.concat([Buffer.from([0xe8, 0x79, 0x67, 0x8e]), Buffer.alloc(32, 0x41)]))
|
||||||
|
assert.throws(() => readCliloc(dir), (err) => err.code === 'COMPRESSED')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Drift over the SET ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('hashSources: fingerprints every source, and counts the overlays', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
|
||||||
|
const fp = hashSources(dir)
|
||||||
|
assert.deepEqual(Object.keys(fp.hashes).sort(), ['clilocs.tsv', 'custom/shard.tsv'])
|
||||||
|
assert.equal(fp.customCount, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sameSources: adding or editing an overlay counts as drift', () => {
|
||||||
|
const dir = tmpdir()
|
||||||
|
write(dir, 'clilocs.tsv', tsv([[1, 'a']]))
|
||||||
|
const before = hashSources(dir).hashes
|
||||||
|
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b']]))
|
||||||
|
const added = hashSources(dir).hashes
|
||||||
|
assert.equal(sameSources(before, added), false, 'a new overlay is drift')
|
||||||
|
|
||||||
|
write(dir, `${CUSTOM_DIR}/shard.tsv`, tsv([[2, 'b changed']]))
|
||||||
|
const edited = hashSources(dir).hashes
|
||||||
|
assert.equal(sameSources(added, edited), false, 'an edited overlay is drift')
|
||||||
|
assert.equal(sameSources(edited, hashSources(dir).hashes), true, 'an untouched set is not')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missingSources: a vanished source is detected, an added one is not "missing"', () => {
|
||||||
|
const loaded = { 'clilocs.tsv': 'aaa', 'custom/shard.tsv': 'bbb' }
|
||||||
|
assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, loaded), ['custom/shard.tsv'])
|
||||||
|
assert.deepEqual(missingSources({ ...loaded, 'custom/new.tsv': 'ccc' }, loaded), [])
|
||||||
|
// Nothing loaded yet (a first import) is not a vanished source.
|
||||||
|
assert.deepEqual(missingSources({ 'clilocs.tsv': 'aaa' }, null), [])
|
||||||
|
})
|
||||||
@@ -30,14 +30,59 @@ test('touches no database at registration time', () => {
|
|||||||
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
|
assert.deepStrictEqual(ctx.db.query.calls, [], 'register() queried the database')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('registers nothing in slice 0', () => {
|
test('registers exactly what module.json declares', () => {
|
||||||
|
// The loader compares these two in BOTH directions and rejects a mismatch
|
||||||
|
// either way, so a prefix registered without being declared and a prefix
|
||||||
|
// declared without being registered are both module-breaking. Asserting
|
||||||
|
// against the manifest rather than a literal list means the test cannot drift
|
||||||
|
// from the file core actually reads.
|
||||||
const api = fakeApi()
|
const api = fakeApi()
|
||||||
register(fakeCtx(), api)
|
register(fakeCtx(), api)
|
||||||
assert.strictEqual(api.record.routes, null)
|
|
||||||
assert.strictEqual(api.record.streams, null)
|
const manifest = require('../../module.json')
|
||||||
assert.deepStrictEqual(api.record.extensions, [])
|
for (const tier of ['public', 'admin', 'player']) {
|
||||||
assert.deepStrictEqual(api.record.legs, [])
|
assert.deepStrictEqual(
|
||||||
assert.deepStrictEqual(api.record.hooks, {})
|
Object.keys(api.record.routes[tier]).sort(),
|
||||||
|
[...manifest.mounts[tier]].sort(),
|
||||||
|
`${tier} mounts disagree with module.json`,
|
||||||
|
)
|
||||||
|
for (const router of Object.values(api.record.routes[tier])) {
|
||||||
|
assert.strictEqual(typeof router, 'function', `${tier} router is not a router`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
|
||||||
|
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
|
||||||
|
assert.ok(api.record.streams.length > 0)
|
||||||
|
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||||
|
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every registered stream is namespaced or grandfathered', () => {
|
||||||
|
// Core rejects a stream id that carries neither this module's prefix nor a
|
||||||
|
// §6.5 grandfathered name. The seven legacy ids are stored in
|
||||||
|
// `notification_subs` and read by a shipped Android client, so they are
|
||||||
|
// allowlisted rather than renamed — but a NEW id must be namespaced, and this
|
||||||
|
// is where that is caught before an install refuses to load the module.
|
||||||
|
// Copied from core's loader (LEGACY_STREAM_IDS), deliberately rather than
|
||||||
|
// imported — this repo has no dependency on core's source, and a copy that
|
||||||
|
// drifts is caught by the module failing to load, which is the failure this
|
||||||
|
// test exists to move earlier.
|
||||||
|
const GRANDFATHERED = new Set([
|
||||||
|
'server.status', 'idoc.warning', 'champ.start', 'governor.election',
|
||||||
|
'vendor.sale', 'house.idoc', 'account.login',
|
||||||
|
])
|
||||||
|
const api = fakeApi()
|
||||||
|
register(fakeCtx(), api)
|
||||||
|
for (const s of api.record.streams) {
|
||||||
|
assert.ok(
|
||||||
|
s.id.startsWith('uo.') || GRANDFATHERED.has(s.id),
|
||||||
|
`stream "${s.id}" is neither namespaced "uo." nor grandfathered`,
|
||||||
|
)
|
||||||
|
assert.ok(s.label && s.description, `stream "${s.id}" is missing its wire shape`)
|
||||||
|
assert.strictEqual(typeof s.personal, 'boolean')
|
||||||
|
assert.strictEqual(typeof s.requiresLinkedAccount, 'boolean')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('takes a frozen ctx and does not try to write to it', () => {
|
test('takes a frozen ctx and does not try to write to it', () => {
|
||||||
|
|||||||
@@ -71,11 +71,17 @@ test('declared mounts are single lowercase segments in known tiers', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('notification stream ids and announce legs stay namespaced or grandfathered', () => {
|
test('the manifest claims a coherent bundle', () => {
|
||||||
// Nothing to check yet — slice 0 registers neither. The assertion that matters
|
// `capabilities` is published by GET /api/v1/public/modules and is what a
|
||||||
// is that the manifest does not quietly claim capabilities the module does not
|
// client feature-detects against — the SPA and the Android app both read it —
|
||||||
// serve, since `GET /api/v1/public/modules` publishes them to clients.
|
// so it must not claim something the module does not serve. Checked as a shape
|
||||||
assert.deepStrictEqual(manifest.capabilities || [], [])
|
// rather than a list: which capabilities exist is a product decision, that
|
||||||
assert.deepStrictEqual(manifest.mounts || {}, {})
|
// they are non-empty opaque strings is the contract.
|
||||||
assert.deepStrictEqual(manifest.extensions || [], [])
|
for (const c of manifest.capabilities || []) {
|
||||||
|
assert.match(c, /^[a-z][a-z0-9-]*$/, `capability "${c}" is not an opaque lowercase id`)
|
||||||
|
}
|
||||||
|
// Declaring a mount is what makes the prefix this module's; the loader checks
|
||||||
|
// the declaration against what register() actually registers (entry.test.js).
|
||||||
|
assert.ok(Object.keys(manifest.mounts).length > 0, 'a module that mounts nothing serves nothing')
|
||||||
|
assert.deepStrictEqual(manifest.extensions, ['admin.users.detail'])
|
||||||
})
|
})
|
||||||
|
|||||||
77
server/test/newsGump.test.js
Normal file
77
server/test/newsGump.test.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Exercise the News-gump sync decisions against a fake sidecar client by
|
||||||
|
// monkeypatching the shared modules newsGump require()s (same instance) — no DB,
|
||||||
|
// no network.
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
const settings = ctx.settings
|
||||||
|
const newsGump = require('../utils/newsGump')
|
||||||
|
|
||||||
|
let calls
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = { post: [], del: [] }
|
||||||
|
saved.postNews = uoLinkClient.postNews
|
||||||
|
saved.deleteNews = uoLinkClient.deleteNews
|
||||||
|
saved.get = settings.get
|
||||||
|
uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } }
|
||||||
|
uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } }
|
||||||
|
settings.get = async () => null // no gump image configured
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
uoLinkClient.postNews = saved.postNews
|
||||||
|
uoLinkClient.deleteNews = saved.deleteNews
|
||||||
|
settings.get = saved.get
|
||||||
|
})
|
||||||
|
|
||||||
|
const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over })
|
||||||
|
|
||||||
|
test('buildArticle centres the title, links the news list, and respects announce', async () => {
|
||||||
|
const a = await newsGump.buildArticle(newsPost(), { announce: false })
|
||||||
|
assert.equal(a.id, '42')
|
||||||
|
assert.match(a.body, /<CENTER>Double XP Weekend<\/CENTER>/)
|
||||||
|
assert.match(a.body, /Starts Friday\./)
|
||||||
|
assert.match(a.url, /\/site\/news$/)
|
||||||
|
assert.equal(a.announce, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a fresh publish into news pushes with announce=true', async () => {
|
||||||
|
await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false })
|
||||||
|
assert.equal(calls.post.length, 1)
|
||||||
|
assert.equal(calls.post[0].announce, true)
|
||||||
|
assert.equal(calls.del.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an edit of already-published news refreshes silently (announce=false)', async () => {
|
||||||
|
await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true })
|
||||||
|
assert.equal(calls.post.length, 1)
|
||||||
|
assert.equal(calls.post[0].announce, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unpublishing published news pulls the article from the gump', async () => {
|
||||||
|
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true })
|
||||||
|
assert.equal(calls.post.length, 0)
|
||||||
|
assert.deepEqual(calls.del, ['42'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a draft never-published news post does nothing', async () => {
|
||||||
|
await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false })
|
||||||
|
assert.equal(calls.post.length, 0)
|
||||||
|
assert.equal(calls.del.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-news post (e.g. screenshot) is never pushed', async () => {
|
||||||
|
await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false })
|
||||||
|
assert.equal(calls.post.length, 0)
|
||||||
|
})
|
||||||
109
server/test/publicShardOnline.test.js
Normal file
109
server/test/publicShardOnline.test.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Staff-location visibility on GET /public/shard/online. The endpoint is
|
||||||
|
// token-free, so it inspects the caller's session (getUserFromRequest) and only
|
||||||
|
// includes each staff member's in-game location (map/x/y/z) for admins and
|
||||||
|
// moderators. Players and the public still see who is online, but not where.
|
||||||
|
//
|
||||||
|
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||||
|
// so any stray query fails fast instead of hanging. The model + auth are stubbed,
|
||||||
|
// so the DB is never actually hit.
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const ctrl = require('../router/public/shard.controller')
|
||||||
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
const auth = ctx.auth
|
||||||
|
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One online staff member with a location the model would return.
|
||||||
|
const ONLINE_ROW = { serial: '0x1', name: 'Lady Mod', map: 'Felucca', x: 1495, y: 1628, z: 10 }
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
listOnlineLinked: shardState.listOnlineLinked,
|
||||||
|
getUserFromRequest: auth.getUserFromRequest,
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
shardState.listOnlineLinked = originals.listOnlineLinked
|
||||||
|
auth.getUserFromRequest = originals.getUserFromRequest
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stub the model to return the staff member, and the session to the given viewer.
|
||||||
|
function setup(viewer) {
|
||||||
|
shardState.listOnlineLinked = async () => [ONLINE_ROW]
|
||||||
|
auth.getUserFromRequest = () => viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCATION_KEYS = ['map', 'x', 'y', 'z']
|
||||||
|
|
||||||
|
for (const role of ['admin', 'moderator']) {
|
||||||
|
test(`getOnline includes location for a ${role}`, async () => {
|
||||||
|
setup({ id: 1, username: 'staff', role })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getOnline({}, res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.equal(res.body.length, 1)
|
||||||
|
const entry = res.body[0]
|
||||||
|
assert.equal(entry.name, 'Lady Mod')
|
||||||
|
assert.equal(entry.serial, '0x1')
|
||||||
|
assert.equal(entry.map, 'Felucca')
|
||||||
|
assert.equal(entry.x, 1495)
|
||||||
|
assert.equal(entry.y, 1628)
|
||||||
|
assert.equal(entry.z, 10)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getOnline omits location for a logged-in player', async () => {
|
||||||
|
setup({ id: 2, username: 'joe', role: 'player' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getOnline({}, res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
const entry = res.body[0]
|
||||||
|
// Still shows they are online…
|
||||||
|
assert.equal(entry.name, 'Lady Mod')
|
||||||
|
assert.equal(entry.serial, '0x1')
|
||||||
|
// …but the location fields are absent entirely (not null/placeholder).
|
||||||
|
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getOnline omits location for an unauthenticated request', async () => {
|
||||||
|
setup(null) // getUserFromRequest returns null for anon callers
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getOnline({}, res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
const entry = res.body[0]
|
||||||
|
assert.equal(entry.name, 'Lady Mod')
|
||||||
|
assert.equal(entry.serial, '0x1')
|
||||||
|
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// An editor is staff but not admin/moderator — they should not see location.
|
||||||
|
test('getOnline omits location for an editor', async () => {
|
||||||
|
setup({ id: 3, username: 'ed', role: 'editor' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getOnline({}, res)
|
||||||
|
const entry = res.body[0]
|
||||||
|
for (const k of LOCATION_KEYS) assert.ok(!(k in entry), `expected "${k}" to be omitted`)
|
||||||
|
})
|
||||||
227
server/test/shardBroadcast.visibility.test.js
Normal file
227
server/test/shardBroadcast.visibility.test.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
const { test, after, afterEach, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { EventEmitter } = require('node:events')
|
||||||
|
|
||||||
|
// The SSE fan-out is the security boundary (docs/link/v3.md §3.6). Before v3 it
|
||||||
|
// was a static kind allowlist; now each subscriber carries the audience rung it
|
||||||
|
// resolved to at subscribe time, and every frame is gated + field-projected per
|
||||||
|
// viewer. These tests pin the properties that must hold no matter how the config
|
||||||
|
// is set:
|
||||||
|
//
|
||||||
|
// - the admin channel always gets the frame verbatim;
|
||||||
|
// - a public subscriber never receives an unmapped kind;
|
||||||
|
// - acct / webId never reach a public subscriber, at any rung below admin;
|
||||||
|
// - two subscribers at different rungs get different frames from one event;
|
||||||
|
// - a viewer's rung is frozen at subscribe time, not re-read per frame;
|
||||||
|
// - if the visibility config can't be read, nothing goes out on public.
|
||||||
|
|
||||||
|
const broadcast = require('../utils/shardBroadcast')
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
const model = require('../model/shardVisibility/shardVisibility.model')
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
listAll: model.listAll,
|
||||||
|
listForUser: shardLinks.listForUser,
|
||||||
|
getConfig: visibility.getConfig,
|
||||||
|
viewerLevel: visibility.viewerLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
model.listAll = async () => []
|
||||||
|
shardLinks.listForUser = async () => []
|
||||||
|
visibility.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
broadcast.closeAll()
|
||||||
|
model.listAll = originals.listAll
|
||||||
|
shardLinks.listForUser = originals.listForUser
|
||||||
|
visibility.getConfig = originals.getConfig
|
||||||
|
visibility.viewerLevel = originals.viewerLevel
|
||||||
|
visibility.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
// A fake req/res pair that records everything written to the stream.
|
||||||
|
function fakeClient() {
|
||||||
|
const req = new EventEmitter()
|
||||||
|
const writes = []
|
||||||
|
const res = {
|
||||||
|
writeHead() {},
|
||||||
|
write(chunk) {
|
||||||
|
writes.push(chunk)
|
||||||
|
},
|
||||||
|
end() {},
|
||||||
|
on() {},
|
||||||
|
}
|
||||||
|
// Frames only — drop the SSE comments/retry preamble and keepalive pings.
|
||||||
|
const frames = () =>
|
||||||
|
writes
|
||||||
|
.filter((w) => w.startsWith('data: '))
|
||||||
|
.map((w) => JSON.parse(w.slice('data: '.length).trim()))
|
||||||
|
return { req, res, frames }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribeAt(level, channel = 'public') {
|
||||||
|
const client = fakeClient()
|
||||||
|
visibility.viewerLevel = async () => level
|
||||||
|
await broadcast.subscribe(client.req, client.res, channel)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUILD_FRAME = {
|
||||||
|
kind: 'guild.update',
|
||||||
|
id: 7,
|
||||||
|
name: 'The Nameless',
|
||||||
|
abbr: 'TN',
|
||||||
|
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the admin channel receives the frame verbatim, acct and webId included', async () => {
|
||||||
|
const admin = await subscribeAt('admin', 'admin')
|
||||||
|
await broadcast.broadcast(GUILD_FRAME)
|
||||||
|
const [frame] = admin.frames()
|
||||||
|
assert.deepEqual(frame, GUILD_FRAME)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a public subscriber never sees acct or webId, at any rung below admin', async () => {
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||||
|
const client = await subscribeAt(level)
|
||||||
|
await broadcast.broadcast(GUILD_FRAME)
|
||||||
|
const [frame] = client.frames()
|
||||||
|
assert.ok(frame, `${level} should receive the guild frame`)
|
||||||
|
assert.equal(frame.leader.name, 'Darrow')
|
||||||
|
assert.equal('acct' in frame.leader, false, `${level} must not see acct`)
|
||||||
|
assert.equal('webId' in frame.leader, false, `${level} must not see webId`)
|
||||||
|
broadcast.closeAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unmapped kind reaches the admin channel and nobody else', async () => {
|
||||||
|
const anon = await subscribeAt('anonymous')
|
||||||
|
const staff = await subscribeAt('staff')
|
||||||
|
const admin = await subscribeAt('admin', 'admin')
|
||||||
|
|
||||||
|
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'vendor.sale']) {
|
||||||
|
await broadcast.broadcast({ kind, secret: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(anon.frames(), [])
|
||||||
|
assert.deepEqual(staff.frames(), [])
|
||||||
|
assert.equal(admin.frames().length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one event yields different frames for subscribers at different rungs', async () => {
|
||||||
|
model.listAll = async () => [
|
||||||
|
{
|
||||||
|
feature: 'guilds',
|
||||||
|
enabled: true,
|
||||||
|
audience: 'anonymous',
|
||||||
|
stream: true,
|
||||||
|
fieldRules: { abbr: 'staff' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
visibility.invalidate()
|
||||||
|
|
||||||
|
const anon = await subscribeAt('anonymous')
|
||||||
|
const staff = await subscribeAt('staff')
|
||||||
|
await broadcast.broadcast(GUILD_FRAME)
|
||||||
|
|
||||||
|
assert.equal('abbr' in anon.frames()[0], false)
|
||||||
|
assert.equal(staff.frames()[0].abbr, 'TN')
|
||||||
|
// Both still lose the locked fields.
|
||||||
|
assert.equal('acct' in staff.frames()[0].leader, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('raising a feature audience cuts off the lower rungs mid-stream', async () => {
|
||||||
|
const anon = await subscribeAt('anonymous')
|
||||||
|
const player = await subscribeAt('player')
|
||||||
|
|
||||||
|
await broadcast.broadcast(GUILD_FRAME)
|
||||||
|
assert.equal(anon.frames().length, 1)
|
||||||
|
assert.equal(player.frames().length, 1)
|
||||||
|
|
||||||
|
// Config changes DO take effect live — only the viewer's rung is frozen.
|
||||||
|
model.listAll = async () => [
|
||||||
|
{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} },
|
||||||
|
]
|
||||||
|
visibility.invalidate()
|
||||||
|
|
||||||
|
await broadcast.broadcast(GUILD_FRAME)
|
||||||
|
assert.equal(anon.frames().length, 1, 'anonymous stops receiving')
|
||||||
|
assert.equal(player.frames().length, 2, 'player keeps receiving')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a subscriber's rung is frozen at subscribe time", async () => {
|
||||||
|
const client = await subscribeAt('anonymous')
|
||||||
|
// Even if the resolver would now say "admin", the open connection must not
|
||||||
|
// gain privilege — its level was captured when it subscribed.
|
||||||
|
visibility.viewerLevel = async () => 'admin'
|
||||||
|
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
|
||||||
|
assert.deepEqual(client.frames(), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unresolvable viewer subscribes as anonymous, not as privileged', async () => {
|
||||||
|
const client = fakeClient()
|
||||||
|
visibility.viewerLevel = async () => {
|
||||||
|
throw new Error('session lookup exploded')
|
||||||
|
}
|
||||||
|
await broadcast.subscribe(client.req, client.res, 'public')
|
||||||
|
await broadcast.broadcast({ kind: 'audit.command', command: 'ban' })
|
||||||
|
assert.deepEqual(client.frames(), [])
|
||||||
|
|
||||||
|
// ...but it still receives ordinary public traffic.
|
||||||
|
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
|
||||||
|
assert.equal(client.frames().length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unreadable visibility config withholds every public frame', async () => {
|
||||||
|
const client = await subscribeAt('anonymous')
|
||||||
|
visibility.getConfig = async () => {
|
||||||
|
throw new Error('db down')
|
||||||
|
}
|
||||||
|
await broadcast.broadcast(GUILD_FRAME)
|
||||||
|
assert.deepEqual(client.frames(), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a disabled feature stops its kinds without touching others', async () => {
|
||||||
|
model.listAll = async () => [
|
||||||
|
{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} },
|
||||||
|
]
|
||||||
|
visibility.invalidate()
|
||||||
|
|
||||||
|
const client = await subscribeAt('anonymous')
|
||||||
|
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
|
||||||
|
await broadcast.broadcast({ kind: 'guild.update', id: 7 })
|
||||||
|
|
||||||
|
const kinds = client.frames().map((f) => f.kind)
|
||||||
|
assert.deepEqual(kinds, ['guild.update'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dead client is dropped rather than repeatedly retried', async () => {
|
||||||
|
const client = fakeClient()
|
||||||
|
visibility.viewerLevel = async () => 'anonymous'
|
||||||
|
await broadcast.subscribe(client.req, client.res, 'public')
|
||||||
|
assert.equal(broadcast.stats().publicClients, 1)
|
||||||
|
|
||||||
|
client.res.write = () => {
|
||||||
|
throw new Error('EPIPE')
|
||||||
|
}
|
||||||
|
await broadcast.broadcast({ kind: 'champ.update', serial: '0x1' })
|
||||||
|
assert.equal(broadcast.stats().publicClients, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('broadcast is a no-op for a malformed event', async () => {
|
||||||
|
const client = await subscribeAt('anonymous')
|
||||||
|
await broadcast.broadcast(null)
|
||||||
|
await broadcast.broadcast({})
|
||||||
|
assert.deepEqual(client.frames(), [])
|
||||||
|
})
|
||||||
415
server/test/shardControllerPublic.test.js
Normal file
415
server/test/shardControllerPublic.test.js
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
|
||||||
|
// the end releases the pool so the process exits cleanly.
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the
|
||||||
|
// bits that decide what the anonymous public may and may not see:
|
||||||
|
// - getFeed serves only kinds on the public allowlist (staff audit / cheat /
|
||||||
|
// login events are stored for the admin channel and must never leak here);
|
||||||
|
// - getHouses exposes only IDOC houses and only their location — owner, price,
|
||||||
|
// co-owners and decay detail are staff-only and must be stripped;
|
||||||
|
// - getStatus assembles the connection/economy summary;
|
||||||
|
// - a model failure degrades to a 500, never a thrown/uncaught error.
|
||||||
|
const ctrl = require('../router/public/shard.controller')
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
// The controller now resolves the visibility config and the caller's rung on
|
||||||
|
// every read. Stub the MODEL rather than the util's exports: getConfig() and
|
||||||
|
// project() call the module-internal getConfig, which an exports-level stub does
|
||||||
|
// not intercept — it would still hit the closed DB port and cost a ~10s pool
|
||||||
|
// timeout per test before falling back to these same defaults.
|
||||||
|
const visibilityModel = require('../model/shardVisibility/shardVisibility.model')
|
||||||
|
visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults
|
||||||
|
visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous'
|
||||||
|
|
||||||
|
const DEFAULTS = visibility.compileDefaults()
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
eventsList: shardEvents.list,
|
||||||
|
listIdoc: shardState.listIdoc,
|
||||||
|
onlineCount: shardState.onlineCount,
|
||||||
|
latestEconomy: shardState.latestEconomy,
|
||||||
|
getRuleset: shardState.getRuleset,
|
||||||
|
getSafe: uoLinkConfig.getSafe,
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
shardEvents.list = originals.eventsList
|
||||||
|
shardState.listIdoc = originals.listIdoc
|
||||||
|
shardState.onlineCount = originals.onlineCount
|
||||||
|
shardState.latestEconomy = originals.latestEconomy
|
||||||
|
shardState.getRuleset = originals.getRuleset
|
||||||
|
uoLinkConfig.getSafe = originals.getSafe
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getFeed: the public-safe allowlist is a security boundary ───────────
|
||||||
|
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
|
||||||
|
let queried = false
|
||||||
|
shardEvents.list = async () => {
|
||||||
|
queried = true
|
||||||
|
return [{ kind: 'staff.audit' }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
|
||||||
|
assert.deepEqual(res.body, [])
|
||||||
|
assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed serves a specific kind when it IS public-safe', async () => {
|
||||||
|
const publicKind = [...broadcast.PUBLIC_KINDS][0]
|
||||||
|
let seen
|
||||||
|
shardEvents.list = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return [{ kind: publicKind }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
|
||||||
|
assert.equal(seen.kind, publicKind)
|
||||||
|
assert.equal(seen.limit, 5)
|
||||||
|
assert.equal(res.body[0].kind, publicKind)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed with no kind restricts the query to the kinds THIS viewer may read', async () => {
|
||||||
|
let seen
|
||||||
|
shardEvents.list = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
await ctrl.getFeed({ query: {} }, mockRes())
|
||||||
|
// Resolved from the LIVE config, not the module-load PUBLIC_KINDS constant, so
|
||||||
|
// an admin re-gating a feature takes effect on the stored history too.
|
||||||
|
assert.deepEqual(new Set(seen.kinds), new Set(visibility.visibleKinds('anonymous', DEFAULTS)))
|
||||||
|
// Sanity: a known admin-only kind is absent from what the public feed queries.
|
||||||
|
assert.ok(!seen.kinds.includes('staff.audit'))
|
||||||
|
// The `stream` flag governs SSE fan-out only, so a feature whose live firehose
|
||||||
|
// ships off is still readable from history — the one way this set is WIDER
|
||||||
|
// than PUBLIC_KINDS.
|
||||||
|
for (const kind of broadcast.PUBLIC_KINDS) assert.ok(seen.kinds.includes(kind))
|
||||||
|
assert.ok(seen.kinds.includes('vendor.listing'))
|
||||||
|
assert.ok(!broadcast.PUBLIC_KINDS.has('vendor.listing'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed projects each row against ITS OWN kind\'s feature', async () => {
|
||||||
|
shardEvents.list = async () => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
kind: 'player.death',
|
||||||
|
payload: { kind: 'player.death', actor: { serial: '0x1', name: 'Doomed', acct: 'secret', webId: 99 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
kind: 'guild.join',
|
||||||
|
payload: { kind: 'guild.join', actor: { serial: '0x2', name: 'Joiner', acct: 'secret2', webId: 98 } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: {} }, res)
|
||||||
|
for (const row of res.body) {
|
||||||
|
assert.equal(row.payload.actor.acct, undefined, `${row.kind} leaked acct`)
|
||||||
|
assert.equal(row.payload.actor.webId, undefined, `${row.kind} leaked webId`)
|
||||||
|
assert.ok(row.payload.actor.name, 'the in-game name is still public')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed serves nothing when the viewer may read no kinds at all', async () => {
|
||||||
|
let queried = false
|
||||||
|
shardEvents.list = async () => {
|
||||||
|
queried = true
|
||||||
|
return [{ kind: 'staff.audit' }]
|
||||||
|
}
|
||||||
|
const allGated = Object.fromEntries(
|
||||||
|
Object.entries(DEFAULTS).map(([name, f]) => [name, { ...f, enabled: false }]),
|
||||||
|
)
|
||||||
|
visibility.getConfig = async () => allGated
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: {} }, res)
|
||||||
|
visibility.getConfig = async () => DEFAULTS
|
||||||
|
assert.deepEqual(res.body, [])
|
||||||
|
// An empty allowlist must never fall through to an unfiltered "give me
|
||||||
|
// everything" query.
|
||||||
|
assert.equal(queried, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getHouses: the public house view must strip owner/price ─────────────
|
||||||
|
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
|
||||||
|
shardState.listIdoc = async () => [
|
||||||
|
{
|
||||||
|
serial: 1,
|
||||||
|
name: 'Keep',
|
||||||
|
region: 'Britain',
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
z: 3,
|
||||||
|
// The following are staff-only and must NOT appear in the public payload:
|
||||||
|
ownerName: 'Lord British',
|
||||||
|
ownerAcct: 'secret',
|
||||||
|
price: 999999,
|
||||||
|
coOwners: 'a,b',
|
||||||
|
decay: 'IDOC',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getHouses({}, res)
|
||||||
|
const [h] = res.body
|
||||||
|
assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
|
||||||
|
assert.equal(h.isIdoc, true)
|
||||||
|
assert.equal(h.ownerName, undefined)
|
||||||
|
assert.equal(h.price, undefined)
|
||||||
|
assert.equal(h.coOwners, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getIdoc: the flattened owner fields are a security boundary too ──────
|
||||||
|
test('getIdoc never serves the owner game account to a viewer below admin', async () => {
|
||||||
|
shardState.listIdoc = async () => [
|
||||||
|
{
|
||||||
|
serial: '0x1',
|
||||||
|
name: 'Marble Tower',
|
||||||
|
region: 'Britain',
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
z: 3,
|
||||||
|
ownerSerial: '0x2A01',
|
||||||
|
ownerName: 'Sir Cadmus',
|
||||||
|
ownerAcct: 'cadmus_acct', // flattened spelling of the locked `acct`
|
||||||
|
price: 1250000,
|
||||||
|
isIdoc: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getIdoc({ viewerLevel: level }, res)
|
||||||
|
assert.equal(res.body[0].ownerAcct, undefined, `${level} saw the owner's game account`)
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getIdoc({ viewerLevel: 'admin' }, res)
|
||||||
|
assert.equal(res.body[0].ownerAcct, 'cadmus_acct', 'admin still sees it')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getIdoc gates owner identity and price at `staff`, but never the location', async () => {
|
||||||
|
shardState.listIdoc = async () => [
|
||||||
|
{ serial: '0x1', name: 'Marble Tower', region: 'Britain', map: 'Felucca', x: 1, y: 2, z: 3,
|
||||||
|
ownerSerial: '0x2A01', ownerName: 'Sir Cadmus', price: 1250000, isIdoc: true },
|
||||||
|
]
|
||||||
|
const anon = mockRes()
|
||||||
|
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, anon)
|
||||||
|
assert.equal(anon.body[0].ownerName, undefined)
|
||||||
|
assert.equal(anon.body[0].ownerSerial, undefined)
|
||||||
|
assert.equal(anon.body[0].price, undefined)
|
||||||
|
// The public IDOC board still renders: name, region and location survive.
|
||||||
|
assert.equal(anon.body[0].name, 'Marble Tower')
|
||||||
|
assert.equal(anon.body[0].region, 'Britain')
|
||||||
|
assert.equal(anon.body[0].map, 'Felucca')
|
||||||
|
|
||||||
|
const staff = mockRes()
|
||||||
|
await ctrl.getIdoc({ viewerLevel: 'staff' }, staff)
|
||||||
|
assert.equal(staff.body[0].ownerName, 'Sir Cadmus')
|
||||||
|
assert.equal(staff.body[0].price, 1250000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getIdoc preserves Date columns rather than flattening them to {}', async () => {
|
||||||
|
const when = new Date('2026-07-06T19:32:29.000Z')
|
||||||
|
shardState.listIdoc = async () => [
|
||||||
|
{ serial: '0x1', name: 'Marble Tower', isIdoc: true, lastRefreshed: when, updatedAt: when },
|
||||||
|
]
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getIdoc({ viewerLevel: 'anonymous' }, res)
|
||||||
|
assert.ok(res.body[0].updatedAt instanceof Date, 'a Date must survive projection intact')
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 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')
|
||||||
|
}
|
||||||
|
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 () => ({
|
||||||
|
enabled: true,
|
||||||
|
status: 'connected',
|
||||||
|
pluginConnected: true,
|
||||||
|
lastEventAt: 'ts',
|
||||||
|
})
|
||||||
|
shardState.onlineCount = async () => 12
|
||||||
|
shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getStatus({}, res)
|
||||||
|
assert.equal(res.body.enabled, true)
|
||||||
|
assert.equal(res.body.onlineCount, 12)
|
||||||
|
assert.equal(res.body.economy.gold, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => {
|
||||||
|
throw new Error('pool down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getStatus({}, res) // must resolve, not reject
|
||||||
|
assert.equal(res.statusCode, 500)
|
||||||
|
assert.equal(res.body.message, 'Internal Server Error')
|
||||||
|
})
|
||||||
71
server/test/shardIngest.champsPages.test.js
Normal file
71
server/test/shardIngest.champsPages.test.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const shardIngest = require('../utils/shardIngest')
|
||||||
|
|
||||||
|
// Build a set of stub deps that record the champ/page/state calls the dispatcher
|
||||||
|
// makes, plus a spy shardEvents.append and broadcast. Only the methods the tested
|
||||||
|
// kinds touch need to be real; the rest are no-op async so ingest() never throws.
|
||||||
|
function makeDeps() {
|
||||||
|
const calls = { champUpsert: [], champRemove: [], pageUpsert: [], pageRemove: [], appended: [], broadcast: [] }
|
||||||
|
const noop = async () => {}
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||||
|
shardState: {
|
||||||
|
upsertChamp: async (ev) => { calls.champUpsert.push(ev) },
|
||||||
|
removeChamp: async (serial) => { calls.champRemove.push(serial) },
|
||||||
|
upsertPage: async (ev) => { calls.pageUpsert.push(ev) },
|
||||||
|
removePage: async (id) => { calls.pageRemove.push(id) },
|
||||||
|
// Unused by these kinds but present so any stray routing is a no-op.
|
||||||
|
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop,
|
||||||
|
},
|
||||||
|
uoLinkConfig: { recordStatus: noop },
|
||||||
|
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||||
|
// No-op push fan-out so ingest() stays hermetic (no real relay/DB).
|
||||||
|
pushDispatch: async () => {},
|
||||||
|
log: { warn() {}, info() {}, error() {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => shardIngest.reset())
|
||||||
|
|
||||||
|
test('champ.update routes to shardState.upsertChamp and is not written to the event log', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const ev = { kind: 'champ.update', serial: '0x1', category: 'champion', name: 'Abyss', status: 'active', t: 1 }
|
||||||
|
const r = await shardIngest.ingest(ev, deps)
|
||||||
|
assert.equal(deps.calls.champUpsert.length, 1)
|
||||||
|
assert.equal(deps.calls.champUpsert[0].serial, '0x1')
|
||||||
|
assert.equal(r.logged, false) // champ.* is state-only, not appended to shard_events
|
||||||
|
assert.equal(deps.calls.appended.length, 0)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1) // still broadcast live
|
||||||
|
})
|
||||||
|
|
||||||
|
test('champ.remove routes to shardState.removeChamp', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ kind: 'champ.remove', serial: '0x2', t: 2 }, deps)
|
||||||
|
assert.deepEqual(deps.calls.champRemove, ['0x2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('page.new and page.updated upsert the page; page.closed removes it', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ kind: 'page.new', pageId: '0x24C', type: 'Bug', sender: { name: 'Al' }, t: 3 }, deps)
|
||||||
|
await shardIngest.ingest({ kind: 'page.updated', pageId: '0x24C', handled: true, t: 4 }, deps)
|
||||||
|
await shardIngest.ingest({ kind: 'page.closed', pageId: '0x24C', t: 5 }, deps)
|
||||||
|
assert.equal(deps.calls.pageUpsert.length, 2)
|
||||||
|
assert.deepEqual(deps.calls.pageRemove, ['0x24C'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admin.audit is appended to the event log (moderation history)', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest({ kind: 'admin.audit', action: 'ban', actor: 'web:jane', target: 'griefer', t: 6 }, deps)
|
||||||
|
assert.equal(r.logged, true)
|
||||||
|
assert.equal(deps.calls.appended.length, 1)
|
||||||
|
assert.equal(deps.calls.appended[0].kind, 'admin.audit')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('champ.remove without a serial is a harmless no-op', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ kind: 'champ.remove', t: 7 }, deps)
|
||||||
|
assert.deepEqual(deps.calls.champRemove, [undefined])
|
||||||
|
})
|
||||||
114
server/test/shardIngest.market.test.js
Normal file
114
server/test/shardIngest.market.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const shardIngest = require('../utils/shardIngest')
|
||||||
|
|
||||||
|
// Protocol 3.0 vendor.listing / vendor.listing.remove routing. Same shape as
|
||||||
|
// shardIngest.points.test.js: stubbed deps, asserting where the dispatcher sends
|
||||||
|
// the frame and whether it is appended to the event log.
|
||||||
|
function makeDeps() {
|
||||||
|
const calls = { upserts: [], removes: [], appended: [], broadcast: [] }
|
||||||
|
const noop = async () => {}
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||||
|
shardState: {
|
||||||
|
// Present so any stray routing is a harmless no-op rather than a crash.
|
||||||
|
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||||
|
addEconomySample: noop, setRuleset: noop, upsertPointsBoard: noop,
|
||||||
|
},
|
||||||
|
shardMarket: {
|
||||||
|
upsertVendor: async (ev) => { calls.upserts.push(ev) },
|
||||||
|
removeVendor: async (serial) => { calls.removes.push(serial) },
|
||||||
|
},
|
||||||
|
shardLinks: { removeByAccount: noop },
|
||||||
|
uoLinkConfig: { recordStatus: noop },
|
||||||
|
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||||
|
pushDispatch: async () => {},
|
||||||
|
log: { warn() {}, info() {}, error() {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAME = {
|
||||||
|
kind: 'vendor.listing',
|
||||||
|
t: 1000,
|
||||||
|
serial: '0x40001234',
|
||||||
|
shopName: "Darrow's Bargains",
|
||||||
|
ownerSerial: '0x1A2B',
|
||||||
|
ownerName: 'Darrow',
|
||||||
|
location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
|
||||||
|
count: 2,
|
||||||
|
total: 2,
|
||||||
|
truncated: false,
|
||||||
|
items: [
|
||||||
|
{ serial: '0x40012ABC', itemId: 3922, hue: 0, amount: 1, price: 25000, name: null, cliloc: 1023721 },
|
||||||
|
{ serial: '0x40012ABD', itemId: 7026, hue: 1157, amount: 3, price: 500, name: 'a shard sigil', cliloc: 1041243 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => shardIngest.reset())
|
||||||
|
|
||||||
|
test('vendor.listing routes to the market model with the whole frame', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, deps)
|
||||||
|
assert.equal(deps.calls.upserts.length, 1)
|
||||||
|
const stored = deps.calls.upserts[0]
|
||||||
|
assert.equal(stored.serial, '0x40001234')
|
||||||
|
assert.equal(stored.location.region, 'Britain')
|
||||||
|
assert.equal(stored.items.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vendor.listing.remove routes to removeVendor with the serial', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ kind: 'vendor.listing.remove', t: 2000, serial: '0x40001234' }, deps)
|
||||||
|
assert.deepEqual(deps.calls.removes, ['0x40001234'])
|
||||||
|
assert.equal(deps.calls.upserts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The market IS the state. One frame carries up to 250 listings and the sweep
|
||||||
|
// re-emits a shop on any price change, so logging would turn shard_events into a
|
||||||
|
// price history nobody reads — the strongest case of the three v3 kinds.
|
||||||
|
test('neither market kind is appended to the event log', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const a = await shardIngest.ingest(FRAME, deps)
|
||||||
|
const b = await shardIngest.ingest({ kind: 'vendor.listing.remove', serial: '0x40001234' }, deps)
|
||||||
|
assert.equal(a.logged, false)
|
||||||
|
assert.equal(b.logged, false)
|
||||||
|
assert.equal(deps.calls.appended.length, 0)
|
||||||
|
assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing'), false)
|
||||||
|
assert.equal(shardIngest.LOGGED_KINDS.has('vendor.listing.remove'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Broadcast is unconditional at this layer — whether it actually reaches anyone
|
||||||
|
// is shardBroadcast's call, and the market feature ships with its stream off.
|
||||||
|
test('vendor.listing is handed to the broadcaster', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, deps)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1)
|
||||||
|
assert.equal(deps.calls.broadcast[0].kind, 'vendor.listing')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a backfilled vendor.listing still stores but does not broadcast', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||||
|
assert.equal(deps.calls.upserts.length, 1)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The reconnect backfill replays the whole index through this path, so a single
|
||||||
|
// bad vendor must not abort it.
|
||||||
|
test('an upsertVendor failure does not throw or stop the broadcast', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
deps.shardMarket.upsertVendor = 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each vendor is its own row; the frame is authoritative for that vendor only.
|
||||||
|
test('two vendors are stored independently', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, deps)
|
||||||
|
await shardIngest.ingest({ ...FRAME, serial: '0x40009999', shopName: 'Second Shop' }, deps)
|
||||||
|
assert.deepEqual(deps.calls.upserts.map((v) => v.serial), ['0x40001234', '0x40009999'])
|
||||||
|
})
|
||||||
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('../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)
|
||||||
|
})
|
||||||
121
server/test/shardIngest.protocol2.test.js
Normal file
121
server/test/shardIngest.protocol2.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const shardIngest = require('../utils/shardIngest')
|
||||||
|
|
||||||
|
// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the
|
||||||
|
// methods the tested kinds touch need to be real; the rest are no-op async so
|
||||||
|
// ingest() never throws on an unrelated kind.
|
||||||
|
function makeDeps() {
|
||||||
|
const calls = {
|
||||||
|
guildUpsert: [], guildRemove: [],
|
||||||
|
governorUpsert: [],
|
||||||
|
presenceSet: [],
|
||||||
|
houseRegistry: [], houseRemove: [],
|
||||||
|
linkRemove: [],
|
||||||
|
appended: [], broadcast: [],
|
||||||
|
}
|
||||||
|
const noop = async () => {}
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||||
|
shardState: {
|
||||||
|
upsertGuild: async (ev) => { calls.guildUpsert.push(ev) },
|
||||||
|
removeGuild: async (id) => { calls.guildRemove.push(id) },
|
||||||
|
upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) },
|
||||||
|
setPresence: async (ev) => { calls.presenceSet.push(ev) },
|
||||||
|
upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) },
|
||||||
|
removeHouse: async (serial) => { calls.houseRemove.push(serial) },
|
||||||
|
// Present so any stray routing is a harmless no-op.
|
||||||
|
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||||
|
addEconomySample: noop,
|
||||||
|
},
|
||||||
|
shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } },
|
||||||
|
uoLinkConfig: { recordStatus: noop },
|
||||||
|
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||||
|
// No-op push fan-out so ingest() stays hermetic (no real relay/DB).
|
||||||
|
pushDispatch: async () => {},
|
||||||
|
log: { warn() {}, info() {}, error() {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => shardIngest.reset())
|
||||||
|
|
||||||
|
test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps)
|
||||||
|
assert.equal(deps.calls.guildUpsert.length, 1)
|
||||||
|
assert.equal(deps.calls.guildUpsert[0].id, 1042)
|
||||||
|
assert.equal(r.logged, false) // board state, not appended to shard_events
|
||||||
|
await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps)
|
||||||
|
assert.deepEqual(deps.calls.guildRemove, [1042])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest(
|
||||||
|
{ kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps)
|
||||||
|
assert.equal(r.logged, true)
|
||||||
|
assert.equal(deps.calls.appended.length, 1)
|
||||||
|
assert.equal(deps.calls.appended[0].kind, 'guild.join')
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('city.update routes to upsertGovernor (which also captures term history)', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(
|
||||||
|
{ kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps)
|
||||||
|
assert.equal(deps.calls.governorUpsert.length, 1)
|
||||||
|
assert.equal(deps.calls.governorUpsert[0].city, 'Britain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('presence.online routes to setPresence and is not logged', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest(
|
||||||
|
{ kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps)
|
||||||
|
assert.equal(deps.calls.presenceSet.length, 1)
|
||||||
|
assert.equal(deps.calls.presenceSet[0].count, 42)
|
||||||
|
assert.equal(r.logged, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps)
|
||||||
|
assert.equal(deps.calls.houseRegistry.length, 1)
|
||||||
|
assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234')
|
||||||
|
await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps)
|
||||||
|
assert.deepEqual(deps.calls.houseRemove, ['0x40001234'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('region.enter is broadcast-only — not logged, no state side effect', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest(
|
||||||
|
{ kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps)
|
||||||
|
assert.equal(r.logged, false)
|
||||||
|
assert.equal(deps.calls.appended.length, 0)
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1) // still surfaced live
|
||||||
|
})
|
||||||
|
|
||||||
|
test('account.unlinked reconciles the local link mirror and is logged', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest(
|
||||||
|
{ kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps)
|
||||||
|
assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped
|
||||||
|
assert.equal(r.logged, true) // provisioning audit trail
|
||||||
|
assert.equal(deps.calls.appended[0].kind, 'account.unlinked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('account.audit is logged (provisioning history) but has no state side effect', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const r = await shardIngest.ingest(
|
||||||
|
{ kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps)
|
||||||
|
assert.equal(r.logged, true)
|
||||||
|
assert.equal(deps.calls.linkRemove.length, 0)
|
||||||
|
assert.equal(deps.calls.appended[0].kind, 'account.audit')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => {
|
||||||
|
const broadcast = require('../utils/shardBroadcast')
|
||||||
|
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
|
||||||
|
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
|
||||||
|
})
|
||||||
161
server/test/shardIngest.ruleset.test.js
Normal file
161
server/test/shardIngest.ruleset.test.js
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const shardIngest = require('../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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Shard name fallback ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
|
||||||
|
// publishes that verbatim, which says "unnamed" rather than naming anything — so
|
||||||
|
// the site answers with its own instance name instead of printing the stock
|
||||||
|
// default under a header carrying the real one.
|
||||||
|
//
|
||||||
|
// Applied at INGEST, not on read, because world.ruleset is also broadcast live:
|
||||||
|
// the same object goes to the SSE fan-out, so a read-time fix would be undone by
|
||||||
|
// the next reconnect's frame. These tests assert both halves.
|
||||||
|
|
||||||
|
function withSettings(deps, name) {
|
||||||
|
return { ...deps, settings: { getInstanceName: async () => name } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the stock ServUO shard name is replaced with the instance name', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
|
||||||
|
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the substituted name reaches the live broadcast, not just the store', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
|
||||||
|
assert.equal(deps.calls.broadcast.length, 1)
|
||||||
|
assert.equal(deps.calls.broadcast[0].shard, 'UOMysticmoon')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing or blank shard name gets the same treatment', async () => {
|
||||||
|
for (const shard of [undefined, null, '', ' ']) {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ ...FRAME, shard }, withSettings(deps, 'UOMysticmoon'))
|
||||||
|
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The match is on the whole value, case- and padding-insensitive. A shard that
|
||||||
|
// deliberately calls itself "My Shard Reborn" has named itself and keeps it.
|
||||||
|
test('a real name that merely contains the stock one is left alone', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest({ ...FRAME, shard: 'My Shard Reborn' }, withSettings(deps, 'UOMysticmoon'))
|
||||||
|
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard Reborn')
|
||||||
|
|
||||||
|
const padded = makeDeps()
|
||||||
|
await shardIngest.ingest({ ...FRAME, shard: ' MY SHARD ' }, withSettings(padded, 'UOMysticmoon'))
|
||||||
|
assert.equal(padded.calls.rulesetSet[0].shard, 'UOMysticmoon')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a shard that named itself is never overridden by the brand', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
await shardIngest.ingest(FRAME, withSettings(deps, 'Some Other Brand'))
|
||||||
|
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The settings read is a DB call on a path that must never fail ingest.
|
||||||
|
test('a settings read failure leaves the frame storable', async () => {
|
||||||
|
const deps = makeDeps()
|
||||||
|
const boom = { ...deps, settings: { getInstanceName: async () => { throw new Error('db down') } } }
|
||||||
|
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, boom)
|
||||||
|
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||||
|
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard')
|
||||||
|
})
|
||||||
263
server/test/shardMarket.model.test.js
Normal file
263
server/test/shardMarket.model.test.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
||||||
|
// Nothing here reaches the database: these are the model's PURE parts — the
|
||||||
|
// flatten/shape rules the frame passes through on the way in and out — plus the
|
||||||
|
// visibility projection over the shapes they produce.
|
||||||
|
|
||||||
|
const { test, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const market = require('../model/shardMarket/shardMarket.model')
|
||||||
|
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||||||
|
const clilocDb = require('../model/shardClilocs/shardClilocs.db')
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
|
||||||
|
|
||||||
|
// Stand in for the cliloc table. Without this each unresolved lookup waits out
|
||||||
|
// the pool's 10s acquire timeout against the dead port — the model swallows the
|
||||||
|
// failure exactly as it would in production (an operator who never converted a
|
||||||
|
// cliloc file is in a supported state), so the RESULT is the same either way;
|
||||||
|
// this only stops the suite spending half a minute proving it.
|
||||||
|
const TABLE = new Map([[1023721, 'quarter staff']])
|
||||||
|
clilocDb.lookup = async (numbers) =>
|
||||||
|
numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) }))
|
||||||
|
|
||||||
|
const FRAME = {
|
||||||
|
kind: 'vendor.listing',
|
||||||
|
t: 1000,
|
||||||
|
serial: '0x40001234',
|
||||||
|
shopName: "Darrow's Bargains",
|
||||||
|
ownerSerial: '0x1A2B',
|
||||||
|
ownerName: 'Darrow',
|
||||||
|
location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
|
||||||
|
count: 2,
|
||||||
|
total: 2,
|
||||||
|
truncated: false,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── flattenFrame ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('flattenFrame lifts the nested location into columns', () => {
|
||||||
|
const v = market.flattenFrame(FRAME)
|
||||||
|
assert.equal(v.serial, '0x40001234')
|
||||||
|
assert.equal(v.map, 'Trammel')
|
||||||
|
assert.equal(v.x, 1421)
|
||||||
|
assert.equal(v.region, 'Britain')
|
||||||
|
assert.equal(v.house, "Darrow's Villa")
|
||||||
|
})
|
||||||
|
|
||||||
|
// A vendor standing in the street has no house, and a frame from an older plugin
|
||||||
|
// may have no location at all. Neither is an error.
|
||||||
|
test('flattenFrame tolerates a missing location entirely', () => {
|
||||||
|
const v = market.flattenFrame({ serial: '0x1', shopName: null })
|
||||||
|
assert.equal(v.map, null)
|
||||||
|
assert.equal(v.x, null)
|
||||||
|
assert.equal(v.region, null)
|
||||||
|
assert.equal(v.house, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// `total` is what the SHOP holds; `count` is what the frame carried. A truncated
|
||||||
|
// shop must not report its published slice as its size, or the page says
|
||||||
|
// "showing 250 of 250" for a vendor holding three thousand stacks.
|
||||||
|
test('flattenFrame keeps the shop total separate from the published count', () => {
|
||||||
|
const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true })
|
||||||
|
assert.equal(v.itemTotal, 3104)
|
||||||
|
assert.equal(v.truncated, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// An older plugin sends no `total`. Falling back to `count` is right — it is the
|
||||||
|
// only number available and it is correct whenever nothing was truncated.
|
||||||
|
test('flattenFrame falls back to count when total is absent', () => {
|
||||||
|
const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined })
|
||||||
|
assert.equal(v.itemTotal, 7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flattenFrame clips over-length strings rather than letting the insert fail', () => {
|
||||||
|
const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) })
|
||||||
|
assert.equal(v.ownerName.length, 64)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── shapeItems ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// resolveMany never throws and, with no cliloc table reachable, resolves nothing
|
||||||
|
// — which is exactly the state of a shard whose operator never converted one, so
|
||||||
|
// these run against the real function rather than a stub.
|
||||||
|
|
||||||
|
test('shapeItems prefers the item\'s literal name over its cliloc', async () => {
|
||||||
|
const items = await market.shapeItems({
|
||||||
|
items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }],
|
||||||
|
})
|
||||||
|
assert.equal(items[0].displayName, 'a shard sigil')
|
||||||
|
// The cliloc is kept regardless, so a later import can still re-resolve it.
|
||||||
|
assert.equal(items[0].cliloc, 1023721)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shapeItems resolves the cliloc when the item has no literal name', async () => {
|
||||||
|
const items = await market.shapeItems({
|
||||||
|
items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }],
|
||||||
|
})
|
||||||
|
assert.equal(items[0].displayName, 'quarter staff')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The supported state for a shard whose operator never converted a cliloc file:
|
||||||
|
// no name, not a fabricated one. Clients render the item id, exactly as they did
|
||||||
|
// before the table existed.
|
||||||
|
test('shapeItems leaves displayName null for an unknown cliloc', async () => {
|
||||||
|
const items = await market.shapeItems({
|
||||||
|
items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }],
|
||||||
|
})
|
||||||
|
assert.equal(items[0].displayName, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unpriced rows are inventory, not listings. The shard drops them too; enforcing
|
||||||
|
// it here as well means a plugin that stops doing so cannot put un-buyable rows
|
||||||
|
// on the market page.
|
||||||
|
test('shapeItems drops unpriced listings', async () => {
|
||||||
|
const items = await market.shapeItems({
|
||||||
|
items: [
|
||||||
|
{ serial: '0x1', itemId: 1, price: 0 },
|
||||||
|
{ serial: '0x2', itemId: 2, price: -1 },
|
||||||
|
{ serial: '0x3', itemId: 3, price: 5 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.deepEqual(items.map((i) => i.serial), ['0x3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shapeItems caps a pathological frame', async () => {
|
||||||
|
const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({
|
||||||
|
serial: `0x${i}`,
|
||||||
|
itemId: 1,
|
||||||
|
price: 1,
|
||||||
|
}))
|
||||||
|
const items = await market.shapeItems({ items: many })
|
||||||
|
assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shapeItems tolerates a frame with no items array', async () => {
|
||||||
|
assert.deepEqual(await market.shapeItems({}), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Visibility projection ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The regression that matters. Part A pre-wired `market.ownerName` and
|
||||||
|
// `market.location` before the frame existed, and the sibling rule it pre-wired
|
||||||
|
// for leaderboards (`characterName`) turned out to be INERT because projectValue
|
||||||
|
// matches literal JSON keys. These assert the market rules actually bite — on the
|
||||||
|
// read model AND on the wire frame, which is why both carry the same key names.
|
||||||
|
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
|
||||||
|
const listing = market.shapeListing({
|
||||||
|
serial: '0x40012ABC',
|
||||||
|
item_id: 3922,
|
||||||
|
hue: 0,
|
||||||
|
amount: 1,
|
||||||
|
price: 25000,
|
||||||
|
name: null,
|
||||||
|
cliloc: 1023721,
|
||||||
|
display_name: 'quarter staff',
|
||||||
|
child: 0,
|
||||||
|
vendor_serial: '0x40001234',
|
||||||
|
shop_name: "Darrow's Bargains",
|
||||||
|
owner_serial: '0x1A2B',
|
||||||
|
owner_name: 'Darrow',
|
||||||
|
map: 'Trammel',
|
||||||
|
x: 1421,
|
||||||
|
y: 1699,
|
||||||
|
z: 0,
|
||||||
|
region: 'Britain',
|
||||||
|
house: "Darrow's Villa",
|
||||||
|
updated_at: new Date(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
test('market defaults expose owner and location (they are already public in game)', () => {
|
||||||
|
const out = visibility.projectFeature('market', listing, 'anonymous', config)
|
||||||
|
assert.equal(out.vendor.ownerName, 'Darrow')
|
||||||
|
assert.equal(out.vendor.location.region, 'Britain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tightening market.ownerName hides it from below that rung', () => {
|
||||||
|
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } }
|
||||||
|
const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
|
||||||
|
const staff = visibility.projectFeature('market', listing, 'staff', tightened)
|
||||||
|
assert.equal('ownerName' in anon.vendor, false)
|
||||||
|
assert.equal(staff.vendor.ownerName, 'Darrow')
|
||||||
|
// The shop name is a separate field and must survive — hiding the owner is not
|
||||||
|
// the same as hiding the shop.
|
||||||
|
assert.equal(anon.vendor.shopName, "Darrow's Bargains")
|
||||||
|
})
|
||||||
|
|
||||||
|
// The whole reason `location` is one nested object: a single rule has to take the
|
||||||
|
// facet, the coordinates, the region and the house together. Five flat keys would
|
||||||
|
// be five rules that drift apart.
|
||||||
|
test('tightening market.location hides the whole location object at once', () => {
|
||||||
|
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } }
|
||||||
|
const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
|
||||||
|
const player = visibility.projectFeature('market', listing, 'player', tightened)
|
||||||
|
assert.equal('location' in anon.vendor, false)
|
||||||
|
assert.equal(player.vendor.location.map, 'Trammel')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The same rules must bite on the LIVE frame, not just the stored read model —
|
||||||
|
// the market's SSE stream is off by default but an admin can turn it on, and a
|
||||||
|
// field rule that only worked on one of the two paths is exactly the leak §3.6.1
|
||||||
|
// records.
|
||||||
|
test('the same rules apply to the raw vendor.listing frame', () => {
|
||||||
|
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } }
|
||||||
|
const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened)
|
||||||
|
assert.equal('ownerName' in out, false)
|
||||||
|
assert.equal('location' in out, false)
|
||||||
|
assert.equal(out.shopName, "Darrow's Bargains")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rule 1 is not configurable and does not depend on the market rules at all: a
|
||||||
|
// frame that somehow carried an account name must never publish it.
|
||||||
|
test('acct and webId are stripped from a market payload regardless of config', () => {
|
||||||
|
const out = visibility.projectFeature(
|
||||||
|
'market',
|
||||||
|
{ serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' },
|
||||||
|
'staff',
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
assert.equal('ownerAcct' in out, false)
|
||||||
|
assert.equal('webId' in out, false)
|
||||||
|
assert.equal(out.shopName, 'Shop')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Both kinds must be attributed to a feature, or rule 2 makes them admin-only by
|
||||||
|
// omission — which would be a silent failure rather than a loud one.
|
||||||
|
test('both market kinds are mapped to the market feature', () => {
|
||||||
|
assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market')
|
||||||
|
assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market')
|
||||||
|
})
|
||||||
|
|
||||||
|
// The market's live firehose is off by default (a page of whole vendor
|
||||||
|
// inventories is the site's biggest bandwidth item and no page needs it live),
|
||||||
|
// but the REST reads are unaffected — which is what `visibleKinds` ignoring the
|
||||||
|
// stream flag encodes.
|
||||||
|
test('market kinds are stream-suppressed by default but still readable', () => {
|
||||||
|
assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true)
|
||||||
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
|
||||||
|
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
|
||||||
|
assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an admin who enables the stream gets the frames', () => {
|
||||||
|
const on = { ...config, market: { ...config.market, stream: true } }
|
||||||
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Guards the stub above against silently doing nothing: if the model stopped
|
||||||
|
// going through db.lookup, every shapeItems assertion would still "pass" by
|
||||||
|
// resolving nothing, which is also what a real miss looks like.
|
||||||
|
test('the cliloc resolver is the path shapeItems resolves through', async () => {
|
||||||
|
const found = await clilocs.resolveMany([1023721])
|
||||||
|
assert.equal(found.get(1023721), 'quarter staff')
|
||||||
|
})
|
||||||
77
server/test/shardState.governorTerms.test.js
Normal file
77
server/test/shardState.governorTerms.test.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const { test, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Term capture lives in the model (shardState.model.upsertGovernor →
|
||||||
|
// recordGovernorTransition) and talks to the db module. We exercise the real
|
||||||
|
// logic against an in-memory fake by monkeypatching the shared db module object
|
||||||
|
// (same instance the model require()s) — no DB, no mocking library.
|
||||||
|
const db = require('../model/shardState/shardState.db')
|
||||||
|
const model = require('../model/shardState/shardState.model')
|
||||||
|
|
||||||
|
let terms // in-memory shard_governor_terms
|
||||||
|
let nextId
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
terms = []
|
||||||
|
nextId = 1
|
||||||
|
for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) {
|
||||||
|
saved[k] = db[k]
|
||||||
|
}
|
||||||
|
db.currentGovernorTerm = async (city) =>
|
||||||
|
terms.find((t) => t.city === city && t.ended_at === null) || null
|
||||||
|
db.closeGovernorTerm = async (id, endedAt) => {
|
||||||
|
const row = terms.find((t) => t.id === id)
|
||||||
|
if (row) row.ended_at = endedAt
|
||||||
|
}
|
||||||
|
db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => {
|
||||||
|
terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name,
|
||||||
|
governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null })
|
||||||
|
}
|
||||||
|
db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture
|
||||||
|
})
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
for (const k of Object.keys(saved)) db[k] = saved[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a repeated city.update with the same governor does NOT open a second term', async () => {
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 })
|
||||||
|
const open = terms.filter((t) => t.ended_at === null)
|
||||||
|
assert.equal(terms.length, 1)
|
||||||
|
assert.equal(open.length, 1)
|
||||||
|
assert.equal(open[0].governor_serial, '0x1')
|
||||||
|
assert.equal(open[0].started_at, 100)
|
||||||
|
restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a governor change closes the old term and opens a new one', async () => {
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 })
|
||||||
|
assert.equal(terms.length, 2)
|
||||||
|
const [first, second] = terms
|
||||||
|
assert.equal(first.governor_serial, '0x1')
|
||||||
|
assert.equal(first.ended_at, 300) // closed at the transition time
|
||||||
|
assert.equal(second.governor_serial, '0x2')
|
||||||
|
assert.equal(second.ended_at, null) // now current
|
||||||
|
assert.equal(second.started_at, 300)
|
||||||
|
restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a seat going vacant closes the term without opening a new one', async () => {
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 })
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 })
|
||||||
|
assert.equal(terms.length, 1)
|
||||||
|
assert.equal(terms[0].ended_at, 400)
|
||||||
|
restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('terms are tracked independently per city', async () => {
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 })
|
||||||
|
await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 })
|
||||||
|
await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op
|
||||||
|
assert.equal(terms.length, 2)
|
||||||
|
assert.equal(terms.filter((t) => t.ended_at === null).length, 2)
|
||||||
|
restore()
|
||||||
|
})
|
||||||
253
server/test/shardState.model.test.js
Normal file
253
server/test/shardState.model.test.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the shard-state model's mapping/derivation rules against a fake
|
||||||
|
// shardState.db (no DB). These are the transforms the ingest dispatcher and the
|
||||||
|
// public read endpoints both depend on:
|
||||||
|
// - a partial online refresh (char.vitals) only writes the keys it carries, so
|
||||||
|
// it never clobbers login-only fields with undefined;
|
||||||
|
// - is_idoc is DERIVED from the decay stage, not trusted from the wire;
|
||||||
|
// - the economy series is clamped, returned oldest→newest, and gold coerced to
|
||||||
|
// a JS number (mariadb hands back BigInt-ish strings for large gold totals);
|
||||||
|
// - an empty presence table reads as a well-formed zero snapshot, not null;
|
||||||
|
// - champ/guild/governor rows fall back to hoisted columns when payload is absent;
|
||||||
|
// - remove/upsert guard against missing identifiers instead of hitting the DB.
|
||||||
|
const db = require('../model/shardState/shardState.db')
|
||||||
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
|
||||||
|
// Records the (id, fields) the model hands to each db write, and serves canned
|
||||||
|
// rows back for reads.
|
||||||
|
let calls
|
||||||
|
const saved = {}
|
||||||
|
const DB_KEYS = [
|
||||||
|
'upsertOnline', 'removeOnline', 'clearOnline', 'insertEconomy', 'listEconomy', 'latestEconomy',
|
||||||
|
'upsertHouse', 'removeHouse', 'listIdocHouses', 'listRegistryHouses', 'setPresence', 'latestPresence',
|
||||||
|
'upsertChamp', 'removeChamp', 'listChamps', 'upsertGuild', 'removeGuild', 'listGuilds',
|
||||||
|
'upsertGovernor', 'listGovernors', 'listGovernorTerms', 'listOnline', 'listOnlineLinked', 'listPages',
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = {}
|
||||||
|
for (const k of DB_KEYS) {
|
||||||
|
saved[k] = db[k]
|
||||||
|
calls[k] = []
|
||||||
|
db[k] = async (...args) => {
|
||||||
|
calls[k].push(args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of DB_KEYS) db[k] = saved[k]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── partial online refresh must not clobber ─────────────────────────────
|
||||||
|
test('upsertOnline drops undefined keys so a vitals refresh keeps login fields', async () => {
|
||||||
|
// A char.vitals event carries hits but not name/acct — those must not be sent as
|
||||||
|
// undefined columns (which would overwrite the login row).
|
||||||
|
await shardState.upsertOnline({ serial: 5, hits: 40, hitsMax: 100 })
|
||||||
|
const [serial, fields] = calls.upsertOnline[0]
|
||||||
|
assert.equal(serial, 5)
|
||||||
|
assert.deepEqual(fields, { hits: 40, hits_max: 100 })
|
||||||
|
assert.ok(!('name' in fields), 'name not written when absent from the event')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertOnline maps camelCase vitals to snake_case columns', async () => {
|
||||||
|
await shardState.upsertOnline({ serial: 9, name: 'Bob', webId: 3, hitsMax: 90, manaMax: 50, stamMax: 70 })
|
||||||
|
const [, fields] = calls.upsertOnline[0]
|
||||||
|
assert.equal(fields.web_id, 3)
|
||||||
|
assert.equal(fields.hits_max, 90)
|
||||||
|
assert.equal(fields.mana_max, 50)
|
||||||
|
assert.equal(fields.stam_max, 70)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertOnline ignores an event with no serial (never touches the DB)', async () => {
|
||||||
|
await shardState.upsertOnline({ name: 'Nobody' })
|
||||||
|
await shardState.upsertOnline(null)
|
||||||
|
assert.equal(calls.upsertOnline.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── is_idoc is derived, not trusted ─────────────────────────────────────
|
||||||
|
test('upsertHouse derives is_idoc=1 only for the IDOC stage (case-insensitive)', async () => {
|
||||||
|
await shardState.upsertHouse({ serial: 1, stage: 'IDOC' })
|
||||||
|
await shardState.upsertHouse({ serial: 2, stage: 'idoc' })
|
||||||
|
await shardState.upsertHouse({ serial: 3, stage: 'Slightly' })
|
||||||
|
assert.equal(calls.upsertHouse[0][1].is_idoc, 1)
|
||||||
|
assert.equal(calls.upsertHouse[1][1].is_idoc, 1)
|
||||||
|
assert.equal(calls.upsertHouse[2][1].is_idoc, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertHouseRegistry writes in_registry=1 and flattens the owner actor', async () => {
|
||||||
|
await shardState.upsertHouseRegistry({ serial: 7, name: 'Keep', owner: { serial: 20, acct: 'a', name: 'Liege' } })
|
||||||
|
const [serial, fields] = calls.upsertHouse[0]
|
||||||
|
assert.equal(serial, 7)
|
||||||
|
assert.equal(fields.in_registry, 1)
|
||||||
|
assert.equal(fields.owner_serial, 20)
|
||||||
|
assert.equal(fields.owner_name, 'Liege')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertHouseRegistry tolerates an abandoned house (null owner)', async () => {
|
||||||
|
await shardState.upsertHouseRegistry({ serial: 8, name: 'Ruin', owner: null })
|
||||||
|
const [, fields] = calls.upsertHouse[0]
|
||||||
|
assert.equal(fields.owner_serial, null)
|
||||||
|
assert.equal(fields.owner_name, null)
|
||||||
|
assert.equal(fields.in_registry, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── economy series shaping ──────────────────────────────────────────────
|
||||||
|
test('listEconomy clamps the limit, reverses to oldest→newest, and coerces gold to Number', async () => {
|
||||||
|
// db.listEconomy returns newest-first; the model reverses for charting.
|
||||||
|
db.listEconomy = async (n) => {
|
||||||
|
assert.equal(n, 1000, 'limit is clamped to MAX_ECONOMY')
|
||||||
|
return [
|
||||||
|
{ accounts: 3, gold: '9000000000', t: 30 },
|
||||||
|
{ accounts: 2, gold: '20', t: 20 },
|
||||||
|
{ accounts: 1, gold: null, t: 10 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const out = await shardState.listEconomy(999999)
|
||||||
|
assert.deepEqual(out.map((r) => r.t), [10, 20, 30], 'oldest first')
|
||||||
|
assert.equal(out[2].gold, 9000000000)
|
||||||
|
assert.equal(typeof out[2].gold, 'number')
|
||||||
|
assert.equal(out[0].gold, null, 'null gold stays null, not 0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listEconomy floors a non-positive limit to the default', async () => {
|
||||||
|
let seen
|
||||||
|
db.listEconomy = async (n) => {
|
||||||
|
seen = n
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
await shardState.listEconomy(0)
|
||||||
|
assert.equal(seen, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── presence defaults ───────────────────────────────────────────────────
|
||||||
|
test('latestPresence returns a well-formed zero snapshot when nothing is stored', async () => {
|
||||||
|
db.latestPresence = async () => null
|
||||||
|
const out = await shardState.latestPresence()
|
||||||
|
assert.deepEqual(out, { count: 0, byFacet: {}, byRegion: {}, t: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('latestPresence parses JSON string columns from the DB', async () => {
|
||||||
|
db.latestPresence = async () => ({ count: '12', by_facet: '{"felucca":5}', by_region: '{"Britain":3}', t: '99' })
|
||||||
|
const out = await shardState.latestPresence()
|
||||||
|
assert.equal(out.count, 12)
|
||||||
|
assert.deepEqual(out.byFacet, { felucca: 5 })
|
||||||
|
assert.equal(out.t, 99)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── payload fallback shaping ────────────────────────────────────────────
|
||||||
|
test('listChamps returns the stored payload verbatim when present', async () => {
|
||||||
|
const payload = { kind: 'champ.update', serial: 1, name: 'Barracoon', custom: 'field' }
|
||||||
|
db.listChamps = async () => [{ serial: 1, payload: JSON.stringify(payload) }]
|
||||||
|
const out = await shardState.listChamps()
|
||||||
|
assert.deepEqual(out[0], payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listChamps falls back to hoisted columns for a legacy row with no payload', async () => {
|
||||||
|
db.listChamps = async () => [{ serial: 2, name: 'Rikktor', active: 1, boss_up: 0, payload: null }]
|
||||||
|
const out = await shardState.listChamps()
|
||||||
|
assert.equal(out[0].kind, 'champ.update')
|
||||||
|
assert.equal(out[0].name, 'Rikktor')
|
||||||
|
assert.equal(out[0].active, true)
|
||||||
|
assert.equal(out[0].bossUp, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listGuilds falls back to a shaped leader object when payload is absent', async () => {
|
||||||
|
db.listGuilds = async () => [{ id: 1, name: 'Order', leader_serial: 5, leader_name: 'Cap', payload: null }]
|
||||||
|
const out = await shardState.listGuilds()
|
||||||
|
assert.equal(out[0].leader.serial, 5)
|
||||||
|
assert.equal(out[0].leader.name, 'Cap')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── guards against missing identifiers ──────────────────────────────────
|
||||||
|
test('remove helpers are no-ops on a falsy id (never call the DB)', async () => {
|
||||||
|
await shardState.removeChamp(undefined)
|
||||||
|
await shardState.removeHouse('')
|
||||||
|
await shardState.removeGuild(null)
|
||||||
|
assert.equal(calls.removeChamp.length, 0)
|
||||||
|
assert.equal(calls.removeHouse.length, 0)
|
||||||
|
assert.equal(calls.removeGuild.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeGuild treats id 0 as a real id (0 != null) but skips null/undefined', async () => {
|
||||||
|
await shardState.removeGuild(0)
|
||||||
|
assert.equal(calls.removeGuild.length, 1, 'guild id 0 is valid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertChamp/upsertGuild/upsertGovernor ignore events missing their key', async () => {
|
||||||
|
await shardState.upsertChamp({ name: 'no serial' })
|
||||||
|
await shardState.upsertGuild({ name: 'no id' })
|
||||||
|
await shardState.upsertGovernor({ governor: {} }) // no city
|
||||||
|
assert.equal(calls.upsertChamp.length, 0)
|
||||||
|
assert.equal(calls.upsertGuild.length, 0)
|
||||||
|
assert.equal(calls.upsertGovernor.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── read-shaping locks the camelCase API/app contract ───────────────────
|
||||||
|
// A field-name regression in these serializers silently breaks the public site
|
||||||
|
// and the Android client, so pin the shapes the read endpoints emit.
|
||||||
|
test('listOnline maps snake_case columns to the camelCase player shape', async () => {
|
||||||
|
db.listOnline = async () => [
|
||||||
|
{ serial: 1, name: 'A', acct: 'acc', web_id: 7, hits: 10, hits_max: 100, mana_max: 50, stam_max: 60, updated_at: 'ts' },
|
||||||
|
]
|
||||||
|
const [p] = await shardState.listOnline()
|
||||||
|
assert.equal(p.webId, 7)
|
||||||
|
assert.equal(p.hitsMax, 100)
|
||||||
|
assert.equal(p.manaMax, 50)
|
||||||
|
assert.equal(p.stamMax, 60)
|
||||||
|
assert.equal(p.updatedAt, 'ts')
|
||||||
|
assert.ok(!('web_id' in p), 'no snake_case leaks into the API shape')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listIdoc shapes houses and coerces isIdoc/price', async () => {
|
||||||
|
db.listIdocHouses = async () => [{ serial: 3, is_idoc: 1, price: '5000', in_registry: 1, owner_serial: 2 }]
|
||||||
|
const [h] = await shardState.listIdoc()
|
||||||
|
assert.equal(h.isIdoc, true)
|
||||||
|
assert.equal(h.price, 5000)
|
||||||
|
assert.equal(typeof h.price, 'number')
|
||||||
|
assert.equal(h.inRegistry, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listPages folds the sender columns into a nested actor and coerces flags', async () => {
|
||||||
|
db.listPages = async () => [
|
||||||
|
{ page_id: 42, type: 'gm', sender_name: 'Help', sender_acct: 'x', web_id: 9, handled: 0, sent_ms: '1234', payload: null },
|
||||||
|
]
|
||||||
|
const [pg] = await shardState.listPages()
|
||||||
|
assert.equal(pg.pageId, 42)
|
||||||
|
assert.deepEqual(pg.sender, { serial: 42, name: 'Help', acct: 'x', webId: 9 })
|
||||||
|
assert.equal(pg.handled, false)
|
||||||
|
assert.equal(pg.sentMs, 1234)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listGovernors falls back to a shaped governor object when payload is absent', async () => {
|
||||||
|
db.listGovernors = async () => [
|
||||||
|
{ city: 'Britain', governor_serial: 5, governor_name: 'Lord', governor_acct: 'a', election_phase: 'none', payload: null },
|
||||||
|
]
|
||||||
|
const [g] = await shardState.listGovernors()
|
||||||
|
assert.equal(g.kind, 'city.update')
|
||||||
|
assert.equal(g.city, 'Britain')
|
||||||
|
assert.equal(g.governor.name, 'Lord')
|
||||||
|
assert.equal(g.governorElect, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listGovernorHistory coerces started/ended timestamps to numbers and clamps the limit', async () => {
|
||||||
|
let seenLimit
|
||||||
|
db.listGovernorTerms = async (city, n) => {
|
||||||
|
seenLimit = n
|
||||||
|
return [{ city, governor_serial: 1, governor_name: 'X', started_at: '100', ended_at: null, votes: 3 }]
|
||||||
|
}
|
||||||
|
const out = await shardState.listGovernorHistory('Trinsic', 99999)
|
||||||
|
assert.equal(seenLimit, 500) // clamped to the 500 max
|
||||||
|
assert.equal(out[0].startedAt, 100)
|
||||||
|
assert.equal(typeof out[0].startedAt, 'number')
|
||||||
|
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
|
||||||
|
})
|
||||||
151
server/test/shardStreams.test.js
Normal file
151
server/test/shardStreams.test.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// `mapShardEvent` and the shard-event push fan-out, moved out of core's
|
||||||
|
// pushDispatch.test.js in Phase 3 (MODULE_SYSTEM.md §2.7.1).
|
||||||
|
//
|
||||||
|
// Core keeps the push INFRASTRUCTURE and its tests — the SSRF guard on an ntfy
|
||||||
|
// endpoint, and `publish()` sending a content-free tickle. What is here is the
|
||||||
|
// CATALOG and the mapping into it: which shard event becomes which stream, which
|
||||||
|
// kinds are owner-keyed, and which must never produce a public target. That last
|
||||||
|
// one is a security boundary and it is module-owned by design (MODULE_API.md
|
||||||
|
// §2.4) — the kinds, the streams and the public-safety filter are one file that
|
||||||
|
// moves together.
|
||||||
|
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { fakeCtx } = require('./_fakes')
|
||||||
|
require('../core').init(fakeCtx())
|
||||||
|
|
||||||
|
const { mapShardEvent, createTracker } = require('../config/shardStreams')
|
||||||
|
const shardPush = require('../utils/shardPush')
|
||||||
|
|
||||||
|
// Moved with the fromShardEvent tests. They set NTFY_* because the push
|
||||||
|
// dispatcher they hand results to reads them — core's variables, read by core's
|
||||||
|
// code, which is why this stays a plain env helper rather than becoming
|
||||||
|
// something on ctx: the module never reads these itself.
|
||||||
|
// ── mapShardEvent ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('server.hello / shutdown / crashed map to the public server.status stream', () => {
|
||||||
|
const t = createTracker()
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [
|
||||||
|
{ streamId: 'server.status', ref: 'up:b1' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }])
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => {
|
||||||
|
const t = createTracker()
|
||||||
|
const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t)
|
||||||
|
assert.deepEqual(out, [
|
||||||
|
{ streamId: 'idoc.warning', ref: '0x40' },
|
||||||
|
{ streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' },
|
||||||
|
])
|
||||||
|
// A non-IDOC decay stage produces nothing.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('champ.update fires champ.start only on the inactive→active transition', () => {
|
||||||
|
const t = createTracker()
|
||||||
|
// First sight active → start.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
|
||||||
|
{ streamId: 'champ.start', ref: 'c1' },
|
||||||
|
])
|
||||||
|
// Still active → no re-fire.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [])
|
||||||
|
// Goes inactive, then active again → fires again.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), [])
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
|
||||||
|
{ streamId: 'champ.start', ref: 'c1' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('city.update fires governor.election only on a real governor change, never on first sight', () => {
|
||||||
|
const t = createTracker()
|
||||||
|
// First sight of the city → no election (could be a reconnect snapshot).
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
|
||||||
|
// Same governor → nothing.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
|
||||||
|
// New governor → election.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [
|
||||||
|
{ streamId: 'governor.election', ref: 'Britain' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => {
|
||||||
|
const t = createTracker()
|
||||||
|
const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t)
|
||||||
|
assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }])
|
||||||
|
|
||||||
|
const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t)
|
||||||
|
assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }])
|
||||||
|
|
||||||
|
// Every personal target carries an ownerAccount (never a bare public push).
|
||||||
|
for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed')
|
||||||
|
|
||||||
|
// A truly sensitive, unmapped kind produces nothing at all.
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), [])
|
||||||
|
assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ── fromShardEvent (owner resolution) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// **These two tests changed shape in the move, and the change is the boundary.**
|
||||||
|
// In core they asserted through `publish()` into `pushDevices` and a captured
|
||||||
|
// fetch — which endpoints were hit, how many requests went out. None of that is
|
||||||
|
// this module's any more: `publish` is `ctx.push.publish`, core's, and the device
|
||||||
|
// registry and the relay are behind it. Reaching for them from here would mean
|
||||||
|
// reaching past `ctx`, which is exactly what §5.1 forbids.
|
||||||
|
//
|
||||||
|
// What remains is what the module actually owns, and it is the part worth
|
||||||
|
// guarding: a game account resolves to a website user through `shardLinks`, a
|
||||||
|
// personal target that resolves to nobody is dropped rather than published, and
|
||||||
|
// a public target publishes with no owner. Core's own pushDispatch tests still
|
||||||
|
// cover the fan-out on the other side of the seam.
|
||||||
|
|
||||||
|
/** Record what the module asked core to publish. */
|
||||||
|
function capturePublish() {
|
||||||
|
const calls = []
|
||||||
|
return { calls, publish: async (streamId, opts) => { calls.push({ streamId, ...opts }) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => {
|
||||||
|
const { calls, publish } = capturePublish()
|
||||||
|
const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) }
|
||||||
|
const deps = { shardLinks, publish, tracker: createTracker() }
|
||||||
|
|
||||||
|
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.equal(calls[0].streamId, 'vendor.sale')
|
||||||
|
assert.equal(calls[0].ownerUserId, 42, 'the game account must resolve to the website user')
|
||||||
|
|
||||||
|
// Unlinked account → nobody to notify → nothing published. Not an error: a
|
||||||
|
// player who never linked their account is the ordinary case, not a fault.
|
||||||
|
calls.length = 0
|
||||||
|
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fromShardEvent publishes a public shard event with no owner', async () => {
|
||||||
|
const { calls, publish } = capturePublish()
|
||||||
|
await shardPush.fromShardEvent(
|
||||||
|
{ kind: 'server.hello', bootId: 'b1' },
|
||||||
|
{ publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } },
|
||||||
|
)
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.equal(calls[0].streamId, 'server.status')
|
||||||
|
assert.equal(calls[0].ownerUserId, undefined, 'a public target must not be owner-keyed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a sensitive kind never reaches publish at all', async () => {
|
||||||
|
// The public-safety filter is module-internal by design (MODULE_API.md §2.4):
|
||||||
|
// the kinds, the streams and the filter are one file that moves together, so
|
||||||
|
// core never holds a rule about data only this module defines. Which makes
|
||||||
|
// this the right side of the boundary for the test too.
|
||||||
|
const { calls, publish } = capturePublish()
|
||||||
|
const deps = { publish, tracker: createTracker(), shardLinks: { getByAccount: async () => null } }
|
||||||
|
for (const kind of ['cheat.fastwalk', 'admin.audit']) {
|
||||||
|
await shardPush.fromShardEvent({ kind, acct: 'bob', actor: 'staff' }, deps)
|
||||||
|
}
|
||||||
|
assert.equal(calls.length, 0)
|
||||||
|
})
|
||||||
426
server/test/shardVisibility.test.js
Normal file
426
server/test/shardVisibility.test.js
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
||||||
|
// Every DB call this suite would make is monkeypatched.
|
||||||
|
|
||||||
|
const { test, after, afterEach, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the visibility framework's INVARIANTS — the rules that make it a
|
||||||
|
// security boundary rather than a convenience filter (docs/link/v3.md §3):
|
||||||
|
//
|
||||||
|
// 1. acct / webId are admin-only ALWAYS and cannot be configured down.
|
||||||
|
// 2. A kind absent from KIND_FEATURE reaches nobody below admin (fail closed).
|
||||||
|
// 3. The compiled defaults reproduce pre-v3 behavior, so installing this
|
||||||
|
// module changes nothing until an admin edits the config.
|
||||||
|
// 4. The ladder is ordered and each rung implies the ones below it.
|
||||||
|
|
||||||
|
const visibility = require('../utils/shardVisibility')
|
||||||
|
const model = require('../model/shardVisibility/shardVisibility.model')
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
|
||||||
|
|
||||||
|
const originals = { listAll: model.listAll, listForUser: shardLinks.listForUser }
|
||||||
|
|
||||||
|
// Default both DB reads to "no rows" so a test that doesn't care never blocks on
|
||||||
|
// the dead pool (each such call would otherwise burn the 10s acquire timeout).
|
||||||
|
// Tests that exercise stored config or a DB failure override these.
|
||||||
|
beforeEach(() => {
|
||||||
|
model.listAll = async () => []
|
||||||
|
shardLinks.listForUser = async () => []
|
||||||
|
visibility.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
model.listAll = originals.listAll
|
||||||
|
shardLinks.listForUser = originals.listForUser
|
||||||
|
visibility.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stub the stored config; the framework merges rows over compiled defaults.
|
||||||
|
function withRows(rows) {
|
||||||
|
model.listAll = async () => rows
|
||||||
|
visibility.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('ladder is ordered and each rung implies the ones below it', () => {
|
||||||
|
assert.deepEqual(visibility.LADDER, ['anonymous', 'logged_in', 'player', 'staff', 'admin'])
|
||||||
|
for (let i = 0; i < visibility.LADDER.length; i += 1) {
|
||||||
|
for (let j = 0; j <= i; j += 1) {
|
||||||
|
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), true)
|
||||||
|
}
|
||||||
|
for (let j = i + 1; j < visibility.LADDER.length; j += 1) {
|
||||||
|
assert.equal(visibility.meets(visibility.LADDER[i], visibility.LADDER[j]), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown rung always loses, on BOTH sides of the comparison', () => {
|
||||||
|
assert.equal(visibility.isLevel('not-a-rung'), false)
|
||||||
|
|
||||||
|
// An unknown REQUIREMENT is satisfied by nobody below admin...
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||||
|
assert.equal(visibility.meets(level, 'not-a-rung'), false, `${level} vs unknown requirement`)
|
||||||
|
}
|
||||||
|
assert.equal(visibility.meets('admin', 'not-a-rung'), true)
|
||||||
|
|
||||||
|
// ...and an unknown VIEWER level grants nothing. This is the direction that
|
||||||
|
// matters: a shared admin fallback would have made a garbage viewer level
|
||||||
|
// pass every gate.
|
||||||
|
for (const required of visibility.LADDER.slice(1)) {
|
||||||
|
assert.equal(visibility.meets('not-a-rung', required), false, `unknown viewer vs ${required}`)
|
||||||
|
assert.equal(visibility.meets(undefined, required), false, `undefined viewer vs ${required}`)
|
||||||
|
assert.equal(visibility.meets(null, required), false, `null viewer vs ${required}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown viewer level cannot see a gated kind or a locked field', async () => {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(visibility.kindVisibleTo('champ.update', 'not-a-rung', config), true) // anonymous-tier: fine
|
||||||
|
assert.equal(visibility.kindVisibleTo('audit.command', 'not-a-rung', config), false)
|
||||||
|
const out = visibility.projectFeature(
|
||||||
|
'guilds',
|
||||||
|
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
|
||||||
|
'not-a-rung',
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
assert.equal('acct' in out.leader, false)
|
||||||
|
assert.equal('webId' in out.leader, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 1: locked fields ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('acct and webId are stripped below admin regardless of feature config', () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
const frame = {
|
||||||
|
kind: 'guild.update',
|
||||||
|
name: 'The Nameless',
|
||||||
|
leader: { serial: '0x1A2B', name: 'Darrow', acct: 'whitlocktech', webId: '42', player: true },
|
||||||
|
}
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||||
|
const out = visibility.projectFeature('guilds', frame, level, config)
|
||||||
|
assert.equal(out.leader.name, 'Darrow', `${level} keeps the character name`)
|
||||||
|
assert.equal(out.leader.serial, '0x1A2B')
|
||||||
|
assert.equal('acct' in out.leader, false, `${level} must not see acct`)
|
||||||
|
assert.equal('webId' in out.leader, false, `${level} must not see webId`)
|
||||||
|
}
|
||||||
|
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
|
||||||
|
assert.equal(asAdmin.leader.acct, 'whitlocktech')
|
||||||
|
assert.equal(asAdmin.leader.webId, '42')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a stored rule trying to loosen a locked field is ignored', async () => {
|
||||||
|
withRows([
|
||||||
|
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
|
||||||
|
])
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
const out = visibility.projectFeature(
|
||||||
|
'guilds',
|
||||||
|
{ leader: { name: 'Darrow', acct: 'whitlocktech', webId: '42' } },
|
||||||
|
'anonymous',
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
assert.equal('acct' in out.leader, false)
|
||||||
|
assert.equal('webId' in out.leader, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rule 1 matches FLATTENED spellings, not just the two canonical keys', () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
// shapeHouse/shapeGuild flatten the actor into `<role>Acct` / `<role>WebId`.
|
||||||
|
// An exact-key check missed every one of these, which is how GET
|
||||||
|
// /public/shard/idoc served the owner's game account to anonymous callers.
|
||||||
|
const row = {
|
||||||
|
serial: '0x1',
|
||||||
|
name: 'Marble Tower',
|
||||||
|
ownerAcct: 'cadmus_acct',
|
||||||
|
leaderWebId: 42,
|
||||||
|
governorAcct: 'blackthorn_acct',
|
||||||
|
}
|
||||||
|
const out = visibility.projectFeature('houses', row, 'staff', config)
|
||||||
|
assert.equal('ownerAcct' in out, false, 'staff must not see a flattened acct')
|
||||||
|
assert.equal('leaderWebId' in out, false)
|
||||||
|
assert.equal('governorAcct' in out, false)
|
||||||
|
assert.equal(out.name, 'Marble Tower', 'ordinary fields are untouched')
|
||||||
|
|
||||||
|
const asAdmin = visibility.projectFeature('houses', row, 'admin', config)
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
// Must not over-match: these are ordinary public fields.
|
||||||
|
for (const key of ['name', 'serial', 'ownerName', 'price', 'contact', 'region']) {
|
||||||
|
assert.equal(visibility.isLockedField(key), false, `${key} must stay configurable`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a Date survives projection instead of collapsing to {}', () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
const when = new Date('2026-07-06T19:32:29.000Z')
|
||||||
|
// The DB-backed read models carry real Date columns; rebuilding one key-by-key
|
||||||
|
// yields `{}` because a Date has no enumerable own properties.
|
||||||
|
const out = visibility.projectFeature('houses', { name: 'Keep', updatedAt: when }, 'anonymous', config)
|
||||||
|
assert.ok(out.updatedAt instanceof Date)
|
||||||
|
assert.equal(out.updatedAt.toISOString(), when.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visibleKinds tracks live config and stays independent of the stream flag', async () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
assert.ok(visibleIncludes(config, 'anonymous', 'guild.update'))
|
||||||
|
// `stream: false` suppresses SSE fan-out only — the stored history stays readable.
|
||||||
|
assert.ok(visibleIncludes(config, 'anonymous', 'vendor.listing'))
|
||||||
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
|
||||||
|
|
||||||
|
const gated = { ...config, guilds: { ...config.guilds, audience: 'staff' } }
|
||||||
|
assert.equal(visibleIncludes(gated, 'anonymous', 'guild.update'), false)
|
||||||
|
assert.ok(visibleIncludes(gated, 'staff', 'guild.update'))
|
||||||
|
|
||||||
|
const off = { ...config, guilds: { ...config.guilds, enabled: false } }
|
||||||
|
assert.equal(visibleIncludes(off, 'admin', 'guild.update'), false)
|
||||||
|
// Rule 2 still holds: an unmapped kind is in nobody's readable set.
|
||||||
|
assert.equal(visibleIncludes(config, 'admin', 'staff.audit'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleIncludes = (config, level, kind) => visibility.visibleKinds(level, config).includes(kind)
|
||||||
|
|
||||||
|
test('projection recurses into arrays and nested actors', () => {
|
||||||
|
const config = visibility.compileDefaults()
|
||||||
|
const rows = [
|
||||||
|
{ city: 'Britain', governor: { name: 'A', acct: 'a', webId: '1' } },
|
||||||
|
{ city: 'Vesper', governor: { name: 'B', acct: 'b' } },
|
||||||
|
]
|
||||||
|
const out = visibility.projectFeature('governors', rows, 'anonymous', config)
|
||||||
|
assert.equal(out.length, 2)
|
||||||
|
assert.equal(out[0].governor.name, 'A')
|
||||||
|
assert.equal('acct' in out[0].governor, false)
|
||||||
|
assert.equal('webId' in out[0].governor, false)
|
||||||
|
assert.equal('acct' in out[1].governor, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 2: fail closed on unmapped kinds ──────────────────────────────────
|
||||||
|
|
||||||
|
test('an unmapped kind reaches nobody below admin', async () => {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
for (const kind of ['audit.command', 'cheat.fastwalk', 'account.login.attempt', 'gold.change', 'made.up.kind']) {
|
||||||
|
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||||
|
assert.equal(visibility.kindVisibleTo(kind, level, config), false, `${kind} @ ${level}`)
|
||||||
|
}
|
||||||
|
assert.equal(visibility.kindVisibleTo(kind, 'admin', config), true, `${kind} @ admin`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the full house registry stays off the kind map (owner/price are staff-only)', () => {
|
||||||
|
assert.equal(visibility.KIND_FEATURE.has('house.update'), false)
|
||||||
|
assert.equal(visibility.KIND_FEATURE.has('house.remove'), false)
|
||||||
|
// house.decay — the IDOC signal the public page renders — IS mapped.
|
||||||
|
assert.equal(visibility.KIND_FEATURE.get('house.decay'), 'houses')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vendor.sale is not public (sales are owner-private)', async () => {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(visibility.kindVisibleTo('vendor.sale', 'anonymous', config), false)
|
||||||
|
assert.equal(visibility.PUBLIC_KINDS.has('vendor.sale'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 3: defaults reproduce pre-v3 behavior ─────────────────────────────
|
||||||
|
|
||||||
|
// The exact allowlist that shipped in shardBroadcast.js before v3. If a change
|
||||||
|
// makes the derived PUBLIC_KINDS differ from this, it is a deliberate widening
|
||||||
|
// or narrowing of what anonymous visitors see and must be reviewed as such.
|
||||||
|
const PRE_V3_PUBLIC_KINDS = [
|
||||||
|
'player.death',
|
||||||
|
'player.murdered',
|
||||||
|
'mob.killed',
|
||||||
|
'house.decay',
|
||||||
|
'quest.complete',
|
||||||
|
'skill.gain',
|
||||||
|
'fame.change',
|
||||||
|
'karma.change',
|
||||||
|
'mob.login',
|
||||||
|
'mob.logout',
|
||||||
|
'economy.supply',
|
||||||
|
'server.hello',
|
||||||
|
'server.shutdown',
|
||||||
|
'server.crashed',
|
||||||
|
'champ.update',
|
||||||
|
'champ.remove',
|
||||||
|
'guild.update',
|
||||||
|
'guild.remove',
|
||||||
|
'guild.join',
|
||||||
|
'city.update',
|
||||||
|
'presence.online',
|
||||||
|
'region.enter',
|
||||||
|
]
|
||||||
|
|
||||||
|
// The kinds v3 deliberately ADDS to the anonymous set. vendor.listing is
|
||||||
|
// pointedly not among them (its feature ships with stream off).
|
||||||
|
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
|
||||||
|
|
||||||
|
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
[...visibility.PUBLIC_KINDS].sort(),
|
||||||
|
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no pre-v3 public kind was dropped', () => {
|
||||||
|
for (const kind of PRE_V3_PUBLIC_KINDS) {
|
||||||
|
assert.equal(visibility.PUBLIC_KINDS.has(kind), true, `${kind} fell out of the public set`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the market stream is off by default but its REST feature is not', async () => {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(config.market.enabled, true)
|
||||||
|
assert.equal(config.market.audience, 'anonymous')
|
||||||
|
assert.equal(config.market.stream, false)
|
||||||
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
|
||||||
|
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('presence location defaults to staff, matching the old admin/moderator gate', async () => {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(config.presence.fields.location, 'staff')
|
||||||
|
assert.equal(visibility.meets('player', 'staff'), false)
|
||||||
|
assert.equal(visibility.meets('staff', 'staff'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every mapped kind names a real feature', () => {
|
||||||
|
for (const [kind, feature] of visibility.KIND_FEATURE) {
|
||||||
|
assert.equal(visibility.isFeature(feature), true, `${kind} → unknown feature ${feature}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Config merge ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a disabled feature is invisible to everyone below admin', async () => {
|
||||||
|
withRows([{ feature: 'champs', enabled: false, audience: 'anonymous', stream: true, fieldRules: {} }])
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(config.champs.enabled, false)
|
||||||
|
assert.equal(visibility.kindVisibleTo('champ.update', 'anonymous', config), false)
|
||||||
|
assert.equal(visibility.kindVisibleTo('champ.update', 'staff', config), false)
|
||||||
|
assert.equal(visibility.visibleFeatures('staff', config).includes('champs'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('raising a feature audience gates the lower rungs out', async () => {
|
||||||
|
withRows([{ feature: 'guilds', enabled: true, audience: 'player', stream: true, fieldRules: {} }])
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(visibility.kindVisibleTo('guild.update', 'anonymous', config), false)
|
||||||
|
assert.equal(visibility.kindVisibleTo('guild.update', 'logged_in', config), false)
|
||||||
|
assert.equal(visibility.kindVisibleTo('guild.update', 'player', config), true)
|
||||||
|
assert.equal(visibility.kindVisibleTo('guild.update', 'staff', config), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unknown stored feature name is ignored, not resurrected', async () => {
|
||||||
|
withRows([{ feature: 'sekrit', enabled: true, audience: 'anonymous', stream: true, fieldRules: {} }])
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal('sekrit' in config, false)
|
||||||
|
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an invalid stored rung falls back to the default rather than failing open', async () => {
|
||||||
|
withRows([{ feature: 'houses', enabled: true, audience: 'nonsense', stream: true, fieldRules: { owner: 'nonsense' } }])
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.equal(config.houses.audience, 'anonymous') // the compiled default
|
||||||
|
assert.equal(config.houses.fields.owner, 'staff') // the compiled default
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a DB failure degrades to compiled defaults, not to everything-public', async () => {
|
||||||
|
model.listAll = async () => {
|
||||||
|
throw new Error('db down')
|
||||||
|
}
|
||||||
|
visibility.invalidate()
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
assert.deepEqual(Object.keys(config).sort(), [...visibility.FEATURE_NAMES].sort())
|
||||||
|
assert.equal(config.presence.fields.location, 'staff')
|
||||||
|
assert.equal(visibility.kindVisibleTo('audit.command', 'anonymous', config), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('viewerLevel resolves the ladder from role and link status', async () => {
|
||||||
|
shardLinks.listForUser = async () => []
|
||||||
|
assert.equal(await visibility.viewerLevel({}), 'anonymous')
|
||||||
|
|
||||||
|
visibility.forgetUser(1)
|
||||||
|
assert.equal(await visibility.viewerLevel({ user: { id: 1, role: 'admin' } }), 'admin')
|
||||||
|
visibility.forgetUser(2)
|
||||||
|
assert.equal(await visibility.viewerLevel({ user: { id: 2, role: 'moderator' } }), 'staff')
|
||||||
|
|
||||||
|
// A member with no linked game account sits at logged_in...
|
||||||
|
visibility.forgetUser(3)
|
||||||
|
assert.equal(await visibility.viewerLevel({ user: { id: 3, role: 'player' } }), 'logged_in')
|
||||||
|
|
||||||
|
// ...and reaches `player` once a link exists.
|
||||||
|
shardLinks.listForUser = async () => [{ account: 'whitlocktech' }]
|
||||||
|
visibility.forgetUser(4)
|
||||||
|
assert.equal(await visibility.viewerLevel({ user: { id: 4, role: 'player' } }), 'player')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('editor is a content role and gets no shard privilege', async () => {
|
||||||
|
// Mapping editor to `staff` here would silently widen what editors can see;
|
||||||
|
// today's modAccess gate is admin|moderator only.
|
||||||
|
shardLinks.listForUser = async () => []
|
||||||
|
visibility.forgetUser(5)
|
||||||
|
assert.equal(await visibility.viewerLevel({ user: { id: 5, role: 'editor' } }), 'logged_in')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a link lookup failure downgrades rather than escalating', async () => {
|
||||||
|
shardLinks.listForUser = async () => {
|
||||||
|
throw new Error('db down')
|
||||||
|
}
|
||||||
|
visibility.forgetUser(6)
|
||||||
|
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
|
||||||
|
})
|
||||||
601
server/test/spawnAtlas.parse.test.js
Normal file
601
server/test/spawnAtlas.parse.test.js
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
parseXml,
|
||||||
|
parseObjects2,
|
||||||
|
parsePoints,
|
||||||
|
parseRegions,
|
||||||
|
parseLocations,
|
||||||
|
parseChampions,
|
||||||
|
buildPlacementIndex,
|
||||||
|
resolveRegion,
|
||||||
|
facetKey,
|
||||||
|
buildFacetIndex,
|
||||||
|
resolveFacetName,
|
||||||
|
slugify,
|
||||||
|
decodeEntities,
|
||||||
|
} = require('../utils/spawnAtlasParse')
|
||||||
|
|
||||||
|
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
||||||
|
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
|
||||||
|
// real shard file, trimmed — not invented shapes.
|
||||||
|
|
||||||
|
// ── parseObjects2 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseObjects2: single type', () => {
|
||||||
|
const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
|
||||||
|
assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
|
||||||
|
// Verbatim from trammel.xml — the case that a naive split(':') destroys.
|
||||||
|
const raw =
|
||||||
|
'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
|
||||||
|
const types = parseObjects2(raw)
|
||||||
|
assert.equal(types.length, 6)
|
||||||
|
assert.deepEqual(
|
||||||
|
types.map((t) => t.type),
|
||||||
|
['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
|
||||||
|
)
|
||||||
|
// MX is per type, not per spawner: the lizardman entry carries 3.
|
||||||
|
assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
|
||||||
|
assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: empty and whitespace values yield no types', () => {
|
||||||
|
assert.deepEqual(parseObjects2(''), [])
|
||||||
|
assert.deepEqual(parseObjects2(' '), [])
|
||||||
|
assert.deepEqual(parseObjects2(null), [])
|
||||||
|
assert.deepEqual(parseObjects2(undefined), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
|
||||||
|
// Left in place these become creatures that do not exist.
|
||||||
|
assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
|
||||||
|
{ type: 'GargishRefugee', max: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: strips argument lists after ","', () => {
|
||||||
|
assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
|
||||||
|
// The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
|
||||||
|
// as two different creatures on the same page.
|
||||||
|
const bare = parseObjects2('Fairy:MX=1')[0]
|
||||||
|
const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
|
||||||
|
assert.equal(slugify(decorated.type), slugify(bare.type))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
|
||||||
|
const raw =
|
||||||
|
'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP/<robe/loottype/blessed' +
|
||||||
|
'/itemid/8259>/blessed/true/name/lord blackthorne/z/:MX=1'
|
||||||
|
assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: a token that is only a directive yields nothing', () => {
|
||||||
|
assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
|
||||||
|
assert.deepEqual(parseObjects2(',1:MX=1'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: a type with no MX token defaults to 1', () => {
|
||||||
|
assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parsePoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POINTS_XML = `<Spawns>
|
||||||
|
<Points>
|
||||||
|
<Name>CovetousSpawner26</Name>
|
||||||
|
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
|
||||||
|
<Map>Trammel</Map>
|
||||||
|
<X>5412</X>
|
||||||
|
<Y>1970</Y>
|
||||||
|
<Width>10</Width>
|
||||||
|
<Height>10</Height>
|
||||||
|
<Range>5</Range>
|
||||||
|
<MaxCount>3</MaxCount>
|
||||||
|
<MinDelay>5</MinDelay>
|
||||||
|
<MaxDelay>10</MaxDelay>
|
||||||
|
<ProximityTriggerSound>500</ProximityTriggerSound>
|
||||||
|
<TODStart>0</TODStart>
|
||||||
|
<TODEnd>0</TODEnd>
|
||||||
|
<TODMode>0</TODMode>
|
||||||
|
<IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Lizardman:MX=3:SB=0</Objects2>
|
||||||
|
</Points>
|
||||||
|
<Points>
|
||||||
|
<Name>Disabled</Name>
|
||||||
|
<Map>Felucca</Map>
|
||||||
|
<X>100</X>
|
||||||
|
<Y>200</Y>
|
||||||
|
<MaxCount>1</MaxCount>
|
||||||
|
<IsRunning>False</IsRunning>
|
||||||
|
<Objects2>Orc:MX=1</Objects2>
|
||||||
|
</Points>
|
||||||
|
</Spawns>`
|
||||||
|
|
||||||
|
test('parsePoints: reads the kept fields and drops the rest', () => {
|
||||||
|
const points = parsePoints(POINTS_XML)
|
||||||
|
assert.equal(points.length, 2)
|
||||||
|
const covetous = points[0]
|
||||||
|
assert.equal(covetous.name, 'CovetousSpawner26')
|
||||||
|
assert.equal(covetous.facet, 'Trammel')
|
||||||
|
assert.equal(covetous.x, 5412)
|
||||||
|
assert.equal(covetous.y, 1970)
|
||||||
|
assert.equal(covetous.width, 10)
|
||||||
|
assert.equal(covetous.range, 5)
|
||||||
|
assert.equal(covetous.maxCount, 3)
|
||||||
|
// Delays are normalised to seconds; this record carries no DelayInSec, which
|
||||||
|
// means minutes.
|
||||||
|
assert.equal(covetous.minDelay, 300)
|
||||||
|
assert.equal(covetous.maxDelay, 600)
|
||||||
|
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||||
|
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||||
|
// under 1 MB.
|
||||||
|
assert.equal(covetous.uniqueId, undefined)
|
||||||
|
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
|
||||||
|
const points = parsePoints(POINTS_XML)
|
||||||
|
assert.equal(points[0].running, true)
|
||||||
|
assert.equal(points[1].running, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: facet comes from <Map>, never the file name', () => {
|
||||||
|
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
|
||||||
|
// one of them.
|
||||||
|
const points = parsePoints(
|
||||||
|
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||||
|
)
|
||||||
|
assert.equal(points[0].facet, 'TerMur')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
|
||||||
|
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
|
||||||
|
assert.deepEqual(points, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: empty document yields no points', () => {
|
||||||
|
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
|
||||||
|
assert.deepEqual(parsePoints(''), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseRegions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ServerRegions>
|
||||||
|
<Facet name="Felucca">
|
||||||
|
<region type="GuardedRegion" priority="50" name="Moongates">
|
||||||
|
<!-- britain -->
|
||||||
|
<rect x="1330" y="1991" width="13" height="13" />
|
||||||
|
<rect x="761" y="741" width="19" height="21" />
|
||||||
|
</region>
|
||||||
|
<region type="MondainRegion" priority="50" name="Prism of Light">
|
||||||
|
<rect x="6400" y="0" width="221" height="255" />
|
||||||
|
<go x="6474" y="188" z="0" />
|
||||||
|
<music name="Dungeon9" />
|
||||||
|
<region type="CrystalField" name="Crystal Field">
|
||||||
|
<rect x="6506" y="83" width="7" height="7" />
|
||||||
|
<zrange min="-4" />
|
||||||
|
</region>
|
||||||
|
<region type="IcyRiver">
|
||||||
|
<rect x="6576" y="73" width="10" height="31" />
|
||||||
|
</region>
|
||||||
|
</region>
|
||||||
|
<region type="TownRegion" priority="10" name="Music Only">
|
||||||
|
<music name="Britain" />
|
||||||
|
</region>
|
||||||
|
</Facet>
|
||||||
|
</ServerRegions>`
|
||||||
|
|
||||||
|
test('parseRegions: flattens nested regions and collects rects', () => {
|
||||||
|
const regions = parseRegions(REGIONS_XML)
|
||||||
|
const byName = new Map(regions.map((r) => [r.name, r]))
|
||||||
|
assert.ok(byName.has('Moongates'))
|
||||||
|
assert.ok(byName.has('Prism of Light'))
|
||||||
|
assert.equal(byName.get('Moongates').rects.length, 2)
|
||||||
|
assert.deepEqual(byName.get('Moongates').rects[0], {
|
||||||
|
x: 1330,
|
||||||
|
y: 1991,
|
||||||
|
width: 13,
|
||||||
|
height: 13,
|
||||||
|
})
|
||||||
|
assert.equal(byName.get('Moongates').facet, 'Felucca')
|
||||||
|
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: a nested child records its parent', () => {
|
||||||
|
const regions = parseRegions(REGIONS_XML)
|
||||||
|
const crystal = regions.find((r) => r.name === 'Crystal Field')
|
||||||
|
assert.ok(crystal, 'the nested named region should be indexed')
|
||||||
|
assert.equal(crystal.parent, 'Prism of Light')
|
||||||
|
assert.equal(crystal.facet, 'Felucca')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: a child with no priority inherits its parent', () => {
|
||||||
|
// Defaulting to 0 instead would sort this specific room below every
|
||||||
|
// top-level region that contains it.
|
||||||
|
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
|
||||||
|
assert.equal(crystal.priority, 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: unnamed regions are skipped but still walked', () => {
|
||||||
|
const regions = parseRegions(REGIONS_XML)
|
||||||
|
// IcyRiver has a type but no name — it cannot label anything.
|
||||||
|
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: a named region with no rects is not indexed', () => {
|
||||||
|
// It can never contain a point, so indexing it only costs scan time.
|
||||||
|
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseLocations ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
|
||||||
|
<places>
|
||||||
|
<parent name="Trammel">
|
||||||
|
<parent name="Dungeons">
|
||||||
|
<parent name="Covetous">
|
||||||
|
<child name="Entrance" x="2499" y="919" z="0" />
|
||||||
|
<child name="Level 1" x="5456" y="1863" z="0" />
|
||||||
|
</parent>
|
||||||
|
<parent name="Despise">
|
||||||
|
<child name="Level 3" x="5407" y="859" z="45" />
|
||||||
|
</parent>
|
||||||
|
</parent>
|
||||||
|
</parent>
|
||||||
|
</places>`
|
||||||
|
|
||||||
|
test('parseLocations: flattens to points carrying their group', () => {
|
||||||
|
const landmarks = parseLocations(LOCATIONS_XML)
|
||||||
|
assert.equal(landmarks.length, 3)
|
||||||
|
const level1 = landmarks.find((l) => l.name === 'Level 1')
|
||||||
|
assert.equal(level1.x, 5456)
|
||||||
|
assert.equal(level1.y, 1863)
|
||||||
|
assert.equal(level1.z, 0)
|
||||||
|
assert.equal(level1.facet, 'Trammel')
|
||||||
|
// "Covetous" is the useful label, not "Level 1".
|
||||||
|
assert.equal(level1.group, 'Covetous')
|
||||||
|
// The facet-level parent is dropped from the path.
|
||||||
|
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Facet canonicalisation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
|
||||||
|
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
|
||||||
|
// matching against whatever the shard's own files declare.
|
||||||
|
|
||||||
|
test('facetKey: collapses spelling differences to one key', () => {
|
||||||
|
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
|
||||||
|
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
|
||||||
|
assert.equal(facetKey('Felucca'), 'felucca')
|
||||||
|
assert.equal(facetKey(''), '')
|
||||||
|
assert.equal(facetKey(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('facetKey: distinct facets keep distinct keys', () => {
|
||||||
|
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
|
||||||
|
// The canonical set comes from the shard's own spawn/region data, not a table.
|
||||||
|
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
|
||||||
|
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||||
|
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
|
||||||
|
assert.equal(resolveFacetName('felucca', index), 'Felucca')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
|
||||||
|
// The whole point: a shard running its own maps gets the same treatment as
|
||||||
|
// the stock ones, with no entry anywhere naming them.
|
||||||
|
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
|
||||||
|
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
|
||||||
|
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
|
||||||
|
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
|
||||||
|
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
|
||||||
|
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
|
||||||
|
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
|
||||||
|
// Keeping its own name is right — a wrong match would silently file a real
|
||||||
|
// custom facet's landmarks under the wrong facet.
|
||||||
|
const index = buildFacetIndex(['The Underdark'])
|
||||||
|
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: prefers the longer match when several could prefix', () => {
|
||||||
|
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
|
||||||
|
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: an unmatched facet keeps its own name', () => {
|
||||||
|
// Inventing a match would be worse than leaving a real custom facet alone.
|
||||||
|
const index = buildFacetIndex(['Felucca'])
|
||||||
|
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
|
||||||
|
assert.equal(resolveFacetName('', index), '')
|
||||||
|
assert.equal(resolveFacetName(null, index), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildFacetIndex: first spelling wins and is stable', () => {
|
||||||
|
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
|
||||||
|
assert.equal(index.size, 1)
|
||||||
|
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints and parseRegions report facet names verbatim', () => {
|
||||||
|
// <Map> and <Facet name> are the authority; they are never rewritten.
|
||||||
|
const points = parsePoints(
|
||||||
|
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||||
|
)
|
||||||
|
assert.equal(points[0].facet, 'Sosaria')
|
||||||
|
const regions = parseRegions(
|
||||||
|
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
|
||||||
|
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
|
||||||
|
)
|
||||||
|
assert.equal(regions[0].facet, 'Sosaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('placement index buckets two spellings of one facet together', () => {
|
||||||
|
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
|
||||||
|
// is keyed apart from the points looking it up, the fallback never fires, and
|
||||||
|
// every unregioned spawn on that facet silently reads "Wilderness".
|
||||||
|
const index = buildPlacementIndex(
|
||||||
|
[],
|
||||||
|
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
|
||||||
|
)
|
||||||
|
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseChampions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<championSystem>
|
||||||
|
<!-- comment describing the schema -->
|
||||||
|
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
|
||||||
|
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
|
||||||
|
</spawn>
|
||||||
|
<spawn name="Wandering" group="FelDungeons">
|
||||||
|
<location x="100" y="200" z="0" map="Felucca" radius="40" />
|
||||||
|
</spawn>
|
||||||
|
</championSystem>`
|
||||||
|
|
||||||
|
test('parseChampions: reads altar name, type and location', () => {
|
||||||
|
const champs = parseChampions(CHAMPIONS_XML)
|
||||||
|
assert.equal(champs.length, 2)
|
||||||
|
assert.deepEqual(champs[0], {
|
||||||
|
name: 'Deceit',
|
||||||
|
group: 'FelDungeons',
|
||||||
|
type: 'UnholyTerror',
|
||||||
|
randomType: false,
|
||||||
|
facet: 'Felucca',
|
||||||
|
x: 5178,
|
||||||
|
y: 708,
|
||||||
|
z: 20,
|
||||||
|
radius: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
|
||||||
|
const champs = parseChampions(CHAMPIONS_XML)
|
||||||
|
assert.equal(champs[1].randomType, true)
|
||||||
|
assert.equal(champs[1].type, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── resolveRegion ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fixtureIndex() {
|
||||||
|
const regions = [
|
||||||
|
{
|
||||||
|
facet: 'Felucca',
|
||||||
|
name: 'Britain',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 10,
|
||||||
|
parent: null,
|
||||||
|
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
facet: 'Felucca',
|
||||||
|
name: 'Britain Bank',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 50,
|
||||||
|
parent: 'Britain',
|
||||||
|
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
facet: 'Felucca',
|
||||||
|
name: 'Wide Low Priority',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 10,
|
||||||
|
parent: null,
|
||||||
|
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const landmarks = [
|
||||||
|
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
|
||||||
|
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
|
||||||
|
]
|
||||||
|
return buildPlacementIndex(regions, landmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveRegion: a contained point takes the region name', () => {
|
||||||
|
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, 'Britain')
|
||||||
|
assert.equal(result.label, 'Britain')
|
||||||
|
assert.equal(result.landmark, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: higher priority wins over a containing region', () => {
|
||||||
|
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, 'Britain Bank')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
|
||||||
|
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
|
||||||
|
// point at priority 10; the specific one must win.
|
||||||
|
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, 'Britain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: rects are half-open — the far edge is outside', () => {
|
||||||
|
const index = fixtureIndex()
|
||||||
|
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
|
||||||
|
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
|
||||||
|
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: falls back to the nearest landmark group', () => {
|
||||||
|
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, null)
|
||||||
|
assert.equal(result.landmark, 'Covetous')
|
||||||
|
assert.equal(result.label, 'Covetous')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
|
||||||
|
// Without the radius cap the nearest landmark is always *some* landmark, and
|
||||||
|
// open countryside would get labelled with a dungeon across the map.
|
||||||
|
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.landmark, null)
|
||||||
|
assert.equal(result.label, 'Wilderness')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: the radius is configurable', () => {
|
||||||
|
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
|
||||||
|
assert.equal(wide.label, 'Covetous')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
|
||||||
|
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
|
||||||
|
assert.equal(result.label, 'Wilderness')
|
||||||
|
assert.equal(result.region, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: does not leak across facets', () => {
|
||||||
|
const index = buildPlacementIndex(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
facet: 'Trammel',
|
||||||
|
name: 'Britain',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 10,
|
||||||
|
parent: null,
|
||||||
|
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
|
||||||
|
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Tokenizer edge cases ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseXml: skips comments, declarations and DOCTYPE', () => {
|
||||||
|
const root = parseXml(
|
||||||
|
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
|
||||||
|
)
|
||||||
|
assert.equal(root.name, 'r')
|
||||||
|
assert.equal(root.children.length, 1)
|
||||||
|
assert.equal(root.children[0].name, 'a')
|
||||||
|
assert.equal(root.children[0].attrs.x, '1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
|
||||||
|
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
|
||||||
|
assert.equal(root.children[0].attrs.name, '1 > 0')
|
||||||
|
assert.equal(root.children[0].attrs.b, '2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: single-quoted attributes are read', () => {
|
||||||
|
const root = parseXml("<r><a name='Mondain' /></r>")
|
||||||
|
assert.equal(root.children[0].attrs.name, 'Mondain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: a stray closing tag is ignored, not fatal', () => {
|
||||||
|
// Hand-maintained shard config: one malformed element should degrade to a
|
||||||
|
// missing element, not abort an otherwise good build.
|
||||||
|
const root = parseXml('<r><a/></b><c/></r>')
|
||||||
|
assert.equal(root.name, 'r')
|
||||||
|
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: empty or element-free input yields null', () => {
|
||||||
|
assert.equal(parseXml(''), null)
|
||||||
|
assert.equal(parseXml('<!-- only a comment -->'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decodeEntities: named, numeric and hex refs', () => {
|
||||||
|
assert.equal(decodeEntities("Mondain's Legacy"), "Mondain's Legacy")
|
||||||
|
assert.equal(decodeEntities('a & b'), 'a & b')
|
||||||
|
assert.equal(decodeEntities('<tag>'), '<tag>')
|
||||||
|
assert.equal(decodeEntities('AB'), 'AB')
|
||||||
|
// An unknown entity is left alone rather than silently eaten.
|
||||||
|
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: decodes entities in attribute values', () => {
|
||||||
|
const root = parseXml('<r><region name="Mondain's Legacy" /></r>')
|
||||||
|
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── slugify ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('slugify: produces URL-safe keys', () => {
|
||||||
|
assert.equal(slugify('Lizardman'), 'lizardman')
|
||||||
|
assert.equal(slugify('Giant Spider'), 'giant-spider')
|
||||||
|
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
|
||||||
|
assert.equal(slugify(' Orc '), 'orc')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Respawn delays: the unit is per record ──────────────────────────────────
|
||||||
|
// XmlSpawner writes minutes by default and switches to seconds only when a
|
||||||
|
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
|
||||||
|
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
|
||||||
|
// assuming either unit is wrong about the other — silently, since both are
|
||||||
|
// plausible respawn times.
|
||||||
|
const DELAY_XML = `<Spawns>
|
||||||
|
<Points>
|
||||||
|
<Name>Minutes</Name>
|
||||||
|
<Map>Sosaria</Map>
|
||||||
|
<X>1</X><Y>1</Y>
|
||||||
|
<MinDelay>5</MinDelay>
|
||||||
|
<MaxDelay>10</MaxDelay>
|
||||||
|
<IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Orc:MX=1</Objects2>
|
||||||
|
</Points>
|
||||||
|
<Points>
|
||||||
|
<Name>Seconds</Name>
|
||||||
|
<Map>Sosaria</Map>
|
||||||
|
<X>2</X><Y>2</Y>
|
||||||
|
<DelayInSec>True</DelayInSec>
|
||||||
|
<MinDelay>5</MinDelay>
|
||||||
|
<MaxDelay>10</MaxDelay>
|
||||||
|
<IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Orc:MX=1</Objects2>
|
||||||
|
</Points>
|
||||||
|
</Spawns>`
|
||||||
|
|
||||||
|
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
|
||||||
|
const [minutes, seconds] = parsePoints(DELAY_XML)
|
||||||
|
assert.equal(minutes.minDelay, 300)
|
||||||
|
assert.equal(minutes.maxDelay, 600)
|
||||||
|
assert.equal(seconds.minDelay, 5)
|
||||||
|
assert.equal(seconds.maxDelay, 10)
|
||||||
|
})
|
||||||
399
server/test/spawnAtlas.source.test.js
Normal file
399
server/test/spawnAtlas.source.test.js
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { test, after, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
AtlasSourceError,
|
||||||
|
aggregateCreatures,
|
||||||
|
displayName,
|
||||||
|
sameSources,
|
||||||
|
hashSources,
|
||||||
|
buildAtlas,
|
||||||
|
PARSER_VERSION,
|
||||||
|
} = require('../utils/spawnAtlasSource')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||||
|
const { ctx } = require('./_setup')
|
||||||
|
const settings = ctx.settings
|
||||||
|
|
||||||
|
|
||||||
|
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
|
||||||
|
// contain a built-in facet list anywhere: a shard may add facets, replace them
|
||||||
|
// outright, or rename them when its maps are updated, and everything has to keep
|
||||||
|
// working with no code change.
|
||||||
|
|
||||||
|
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
|
||||||
|
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||||
|
|
||||||
|
for (const facet of facets) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Spawns', `${facet}.xml`),
|
||||||
|
`<Spawns>
|
||||||
|
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||||
|
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||||||
|
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||||||
|
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
|
||||||
|
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
|
||||||
|
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
|
||||||
|
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
|
||||||
|
<Objects2>Ghost:MX=1</Objects2></Points>
|
||||||
|
</Spawns>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// The location file names its facet differently from <Map>, the real
|
||||||
|
// `Ter Mur` / `Tokuno Islands` drift.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
|
||||||
|
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
|
||||||
|
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Regions.xml'),
|
||||||
|
`<ServerRegions>${facets
|
||||||
|
.map(
|
||||||
|
(facet) => `<Facet name="${facet}">
|
||||||
|
<region type="TownRegion" priority="10" name="${facet} City">
|
||||||
|
<rect x="1000" y="1000" width="500" height="500" />
|
||||||
|
</region></Facet>`,
|
||||||
|
)
|
||||||
|
.join('')}</ServerRegions>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (includeChampions) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Config', 'ChampionSpawns.xml'),
|
||||||
|
`<championSystem><spawn name="Deep" group="G" type="Terror">
|
||||||
|
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
|
||||||
|
</spawn></championSystem>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempTree(options) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
|
||||||
|
writeTree(root, options)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||||||
|
|
||||||
|
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
|
||||||
|
assert.equal(atlas.meta.counts.facets, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
|
||||||
|
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
|
||||||
|
// silently emptied the Ter Mur / Tokuno landmark buckets.
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
|
||||||
|
// And the fallback actually fires, rather than the point reading Wilderness.
|
||||||
|
const far = atlas.points.find((p) => p.name === 'SosariaB')
|
||||||
|
assert.equal(far.landmark, 'Deep Cave')
|
||||||
|
assert.equal(far.label, 'Deep Cave')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: resolves a contained point to its region', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||||
|
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
|
||||||
|
assert.equal(inCity.region, 'Sosaria City')
|
||||||
|
assert.equal(inCity.label, 'Sosaria City')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: drops spawners that are switched off in-world', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||||
|
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
|
||||||
|
assert.equal(atlas.meta.counts.pointsDisabled, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: a champion altar resolves through the same placement index', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||||
|
assert.equal(atlas.champions[0].label, 'Sosaria City')
|
||||||
|
assert.equal(atlas.champions[0].facet, 'Sosaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: a tree with no champion file still builds', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
|
||||||
|
assert.deepEqual(atlas.champions, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: missing path and empty path raise typed errors', () => {
|
||||||
|
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
|
||||||
|
assert.throws(
|
||||||
|
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
|
||||||
|
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
|
||||||
|
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
|
||||||
|
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Hashing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('hashSources: stable across reads, changes when a file changes', () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const first = hashSources(root)
|
||||||
|
assert.ok(sameSources(first, hashSources(root)))
|
||||||
|
|
||||||
|
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
|
||||||
|
assert.equal(sameSources(first, hashSources(root)), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sameSources: a missing or extra file is a difference', () => {
|
||||||
|
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
|
||||||
|
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
|
||||||
|
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
|
||||||
|
assert.equal(sameSources(null, { a: '1' }), false)
|
||||||
|
assert.equal(sameSources({ a: '1' }, null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POINTS = [
|
||||||
|
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
|
||||||
|
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
|
||||||
|
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
|
||||||
|
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||||||
|
assert.equal(lizardman.total, 10)
|
||||||
|
assert.equal(lizardman.points, 3)
|
||||||
|
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aggregateCreatures: differing case collapses to one creature', () => {
|
||||||
|
const creatures = aggregateCreatures(POINTS)
|
||||||
|
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||||||
|
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
|
||||||
|
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayName: most common wins, ties break to the capitalised form', () => {
|
||||||
|
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||||||
|
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||||||
|
// Deterministic regardless of insertion order — a committed artifact is gone,
|
||||||
|
// but a spurious diff in the DB on every restart would be just as wrong.
|
||||||
|
assert.equal(
|
||||||
|
displayName(new Map([['abc', 1], ['abd', 1]])),
|
||||||
|
displayName(new Map([['abd', 1], ['abc', 1]])),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pointTypeRows: collapses a repeated type to the larger max', () => {
|
||||||
|
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
|
||||||
|
// insert and take the whole transaction with it.
|
||||||
|
const rows = shardAtlas.pointTypeRows([
|
||||||
|
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
|
||||||
|
])
|
||||||
|
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
|
||||||
|
const rows = shardAtlas.pointTypeRows([
|
||||||
|
{ types: [{ type: 'A', max: 1 }] },
|
||||||
|
{ types: [{ type: 'B', max: 1 }] },
|
||||||
|
])
|
||||||
|
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The refresh decision ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The boot path's two contracts: it never blocks startup, and it never applies a
|
||||||
|
// facet removal on its own.
|
||||||
|
|
||||||
|
let applied
|
||||||
|
let pendingRow
|
||||||
|
let facetsInDb
|
||||||
|
let metaRow
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
applied = null
|
||||||
|
pendingRow = null
|
||||||
|
facetsInDb = []
|
||||||
|
metaRow = null
|
||||||
|
atlasDb.replaceAtlas = async (atlas) => {
|
||||||
|
applied = atlas
|
||||||
|
return { points: atlas.points.length, creatures: atlas.creatures.length }
|
||||||
|
}
|
||||||
|
atlasDb.getMeta = async () => metaRow
|
||||||
|
atlasDb.getFacets = async () => facetsInDb
|
||||||
|
atlasDb.getPending = async () => pendingRow
|
||||||
|
atlasDb.setPending = async (payload, status) => {
|
||||||
|
pendingRow = { ...payload, status }
|
||||||
|
}
|
||||||
|
atlasDb.clearPending = async () => {
|
||||||
|
pendingRow = null
|
||||||
|
}
|
||||||
|
settings.get = async () => ''
|
||||||
|
process.env.SERVUO_PATH = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: no configured path is skipped, not an error', async () => {
|
||||||
|
const result = await shardAtlas.refresh()
|
||||||
|
assert.equal(result.status, 'skipped')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
|
||||||
|
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
|
||||||
|
assert.equal(result.status, 'unavailable')
|
||||||
|
assert.equal(result.code, 'NOT_FOUND')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a fresh database imports', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
assert.deepEqual(result.addedFacets, ['Sosaria'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
metaRow = buildAtlas(root).meta
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'unchanged')
|
||||||
|
assert.equal(applied, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The hash gate alone would strand an install whose maps never change on
|
||||||
|
// whatever an older build derived: a corrected parse would ship and never reach
|
||||||
|
// the data, because the only thing compared is the tree.
|
||||||
|
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const meta = buildAtlas(root).meta
|
||||||
|
delete meta.parserVersion
|
||||||
|
metaRow = meta
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: --force reimports an unchanged tree', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
metaRow = buildAtlas(root).meta
|
||||||
|
const result = await shardAtlas.refresh({ path: root, force: true })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a NEW facet applies straight away', async () => {
|
||||||
|
// Additions cannot destroy anything an operator would miss.
|
||||||
|
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||||
|
facetsInDb = ['Sosaria']
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.deepEqual(result.addedFacets, ['Underdark'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a REMOVED facet is staged, not applied', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'needsReview')
|
||||||
|
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||||
|
// The critical part: the existing atlas was left alone.
|
||||||
|
assert.equal(applied, null)
|
||||||
|
assert.equal(pendingRow.status, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: approving applies the removal', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(applied, null)
|
||||||
|
|
||||||
|
const result = await shardAtlas.approvePending({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
await shardAtlas.refresh({ path: root })
|
||||||
|
await shardAtlas.rejectPending()
|
||||||
|
assert.equal(pendingRow.status, 'rejected')
|
||||||
|
|
||||||
|
const again = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(again.status, 'unchanged')
|
||||||
|
assert.equal(applied, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: changing the tree asks again after a rejection', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
await shardAtlas.refresh({ path: root })
|
||||||
|
await shardAtlas.rejectPending()
|
||||||
|
|
||||||
|
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
|
||||||
|
const again = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(again.status, 'needsReview')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
|
||||||
|
atlasDb.getMeta = async () => {
|
||||||
|
throw new Error('database is on fire')
|
||||||
|
}
|
||||||
|
atlasDb.getFacets = async () => {
|
||||||
|
throw new Error('still on fire')
|
||||||
|
}
|
||||||
|
atlasDb.replaceAtlas = async () => {
|
||||||
|
throw new Error('and the import too')
|
||||||
|
}
|
||||||
|
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
|
||||||
|
|
||||||
|
const result = await shardAtlas.refreshOnBoot()
|
||||||
|
assert.equal(result.status, 'failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
|
||||||
|
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
|
||||||
|
const result = await shardAtlas.refreshOnBoot()
|
||||||
|
assert.equal(result.status, 'unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an explicit path overrides the configured one', async () => {
|
||||||
|
const configured = tempTree({ facets: ['Configured'] })
|
||||||
|
const override = tempTree({ facets: ['Override'] })
|
||||||
|
settings.get = async () => configured
|
||||||
|
|
||||||
|
const result = await shardAtlas.refresh({ path: override })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.deepEqual(result.addedFacets, ['Override'])
|
||||||
|
})
|
||||||
70
server/test/townCrier.test.js
Normal file
70
server/test/townCrier.test.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// The town-crier leg's own tests, moved out of core's announceJobs.test.js in
|
||||||
|
// Phase 3 (MODULE_SYSTEM.md §2.7.1).
|
||||||
|
//
|
||||||
|
// Core keeps what it owns there — the backoff schedule, the parent-status rollup
|
||||||
|
// and the Discord leg — because those are the announce PIPELINE. What is here is
|
||||||
|
// this module's LEG: how a post becomes town-crier lines, and how the sidecar's
|
||||||
|
// answers classify into done / retry / terminal. The split is the same one the
|
||||||
|
// registry makes.
|
||||||
|
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { fakeCtx } = require('./_fakes')
|
||||||
|
require('../core').init(fakeCtx())
|
||||||
|
|
||||||
|
const townCrier = require('../utils/shardAnnounce')
|
||||||
|
|
||||||
|
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||||
|
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||||
|
const lines = townCrier.buildTownCrierText(
|
||||||
|
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(lines, ['Server Update', 'Big things afoot.', 'https://uom.example/site/news'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||||
|
const lines = townCrier.buildTownCrierText(
|
||||||
|
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
assert.equal(lines[1], 'Hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||||
|
const longTitle = 'x'.repeat(500)
|
||||||
|
const lines = townCrier.buildTownCrierText(
|
||||||
|
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||||
|
assert.ok(lines[0].endsWith('…'))
|
||||||
|
assert.ok(lines.length <= townCrier.MAX_LINES)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||||
|
const lines = townCrier.buildTownCrierText(
|
||||||
|
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||||
|
{ baseUrl: 'https://uom.example' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(lines, ['Only a title', 'https://uom.example/site/news'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||||
|
test('town crier classify: 2xx is done', () => {
|
||||||
|
assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||||
|
for (const status of [400, 401, 409]) {
|
||||||
|
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('town crier classify: shard-transient and network errors retry', () => {
|
||||||
|
for (const status of [503, 504, 500, 0]) {
|
||||||
|
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
74
server/test/uoLinkClient.test.js
Normal file
74
server/test/uoLinkClient.test.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
||||||
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
||||||
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
||||||
|
// not exist here. What a test controls instead is the `ctx` core would have
|
||||||
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
||||||
|
// contract actually promises.
|
||||||
|
|
||||||
|
// Point the DB at a closed port BEFORE requiring the modules (they build the pool).
|
||||||
|
// The uoLinkConfig model is monkeypatched so no query runs.
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// The uo-link REST client's headline contract (see its module header and
|
||||||
|
// CLAUDE.md): it NEVER throws — every call resolves to { ok, data, status, error }
|
||||||
|
// so a public page or an admin poll degrades to "shard unavailable" instead of
|
||||||
|
// 500ing. The regression these tests lock down: resolveConfig() decrypts the
|
||||||
|
// stored auth token, and secretBox.decrypt THROWS when the ciphertext can't be
|
||||||
|
// authenticated (SECRET_ENC_KEY rotated, or a DB dump restored under a different
|
||||||
|
// key). It used to run OUTSIDE call()'s try, so that throw escaped the client and
|
||||||
|
// 500'd every live-shard route.
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
|
||||||
|
|
||||||
|
const origGetWithToken = uoLinkConfig.getWithToken
|
||||||
|
afterEach(() => {
|
||||||
|
uoLinkConfig.getWithToken = origGetWithToken
|
||||||
|
uoLinkClient.invalidateConfig() // drop the 5s config cache between cases
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an undecryptable stored token resolves to { ok: false } instead of throwing', async () => {
|
||||||
|
uoLinkConfig.getWithToken = async () => {
|
||||||
|
// Exactly what crypto's Decipheriv.final() raises on a bad key / tampered blob.
|
||||||
|
throw new Error('Unsupported state or unable to authenticate data')
|
||||||
|
}
|
||||||
|
uoLinkClient.invalidateConfig()
|
||||||
|
|
||||||
|
const result = await uoLinkClient.health()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false, 'must report failure, not throw')
|
||||||
|
assert.equal(result.status, 0)
|
||||||
|
assert.match(result.error, /unreadable/i, 'distinguishes config failure from a dead sidecar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every read helper stays on the { ok:false } contract when config is unreadable', async () => {
|
||||||
|
uoLinkConfig.getWithToken = async () => {
|
||||||
|
throw new Error('Unsupported state or unable to authenticate data')
|
||||||
|
}
|
||||||
|
uoLinkClient.invalidateConfig()
|
||||||
|
|
||||||
|
// The routes that regressed: character sheet, roster and vendor lookups, which
|
||||||
|
// are reachable from both /admin/shard/* and the player-facing /player/shard/*.
|
||||||
|
for (const call of [
|
||||||
|
() => uoLinkClient.getCharBySerial('0x1'),
|
||||||
|
() => uoLinkClient.getRoster('someacct'),
|
||||||
|
() => uoLinkClient.getVendors('someacct'),
|
||||||
|
]) {
|
||||||
|
const result = await call()
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing/blank config still reports "not configured" (unchanged behaviour)', async () => {
|
||||||
|
uoLinkConfig.getWithToken = async () => null
|
||||||
|
uoLinkClient.invalidateConfig()
|
||||||
|
|
||||||
|
const result = await uoLinkClient.health()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.status, 0)
|
||||||
|
assert.match(result.error, /not configured/i)
|
||||||
|
})
|
||||||
52
server/utils/announceLinks.js
Normal file
52
server/utils/announceLinks.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// The three helpers the town-crier leg needs from core's announce pipeline.
|
||||||
|
//
|
||||||
|
// Core owns `announce_jobs`, the worker that drains it and the retry policy;
|
||||||
|
// this module owns one leg of it (MODULE_API.md §2.4). These three lived in
|
||||||
|
// core's `announceJobs.logic` and are reproduced here rather than added to
|
||||||
|
// `ctx`, because each is a few lines of pure string handling with no state and
|
||||||
|
// no policy — the kind of thing a contract member would only make harder to
|
||||||
|
// change on both sides.
|
||||||
|
//
|
||||||
|
// The one that could NOT be vendored is `baseUrl`. Core's version reads
|
||||||
|
// `process.env.APP_BASE_URL`, and §2.7 forbids a module reading core's
|
||||||
|
// environment — it is core's deployment fact, not the module's. So it comes off
|
||||||
|
// `ctx.site.baseUrl` (API 1.1.0), read per call rather than captured, which also
|
||||||
|
// means a module built before an env change keeps agreeing with core after it.
|
||||||
|
|
||||||
|
// NOT destructured. `core.baseUrl` is a getter that resolves `ctx`, so pulling
|
||||||
|
// it out here would run at require time — before `register()` — and throw. Read
|
||||||
|
// it inside the function, where `ctx` exists.
|
||||||
|
const core = require('../core')
|
||||||
|
|
||||||
|
/** Where this deployment is reachable, without a trailing slash. */
|
||||||
|
function baseUrl() {
|
||||||
|
return core.baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public link that goes in an announcement.
|
||||||
|
*
|
||||||
|
* News has no per-post route — core's SPA has only the list — so this links the
|
||||||
|
* list, matching what the pre-pipeline Discord announce did. It names a CORE
|
||||||
|
* route on purpose: the news list is core's page and stays core's through the
|
||||||
|
* whole extraction, so this is a module linking to its host, not a leftover.
|
||||||
|
*/
|
||||||
|
function articleUrl(base) {
|
||||||
|
return `${String(base || '').replace(/\/+$/, '')}/site/news`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squeeze a leg client's `{ ok, status, data, error }` into the one line stored
|
||||||
|
* in `announce_job_legs.last_error` and shown in the admin panel.
|
||||||
|
*/
|
||||||
|
function legError(result) {
|
||||||
|
if (!result) return 'no response'
|
||||||
|
if (result.status) {
|
||||||
|
return result.data && result.data.message
|
||||||
|
? `${result.status}: ${result.data.message}`
|
||||||
|
: result.error || `status ${result.status}`
|
||||||
|
}
|
||||||
|
return result.error || 'request failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { baseUrl, articleUrl, legError }
|
||||||
287
server/utils/clilocParse.js
Normal file
287
server/utils/clilocParse.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Cliloc parsing — the pure half.
|
||||||
|
//
|
||||||
|
// A "cliloc" is UO's localization table: an integer id mapped to a display
|
||||||
|
// string. Items carry a `LabelNumber` rather than a name, so without this table
|
||||||
|
// the site can only render `id 1023721` where the game shows "quarter staff".
|
||||||
|
// The shard already sends the id on every equipment entry (`char.profile`'s
|
||||||
|
// `cliloc` field) and will send one per marketplace listing — the *number* was
|
||||||
|
// never the missing piece, the *table* was.
|
||||||
|
//
|
||||||
|
// This module is fs-free on purpose, exactly like `spawnAtlasParse.js`: the
|
||||||
|
// suite runs in CI where there is no UO client, so every parser here is driven
|
||||||
|
// from inline fixtures. `clilocSource.js` is the only thing that touches disk.
|
||||||
|
//
|
||||||
|
// ── Two input formats, and why ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The client's own `Cliloc.enu` is COMPRESSED (Mythic format) on any modern
|
||||||
|
// client, and decompressing it is a bit-level port of an inverse-BWT coder that
|
||||||
|
// nothing in this stack needs at runtime. ServUO's own bundled `Ultima.StringList`
|
||||||
|
// cannot read it either — which is why `VendorSearch.GetItemName` is already inert
|
||||||
|
// on such a shard and the plugin could not supply names even if we asked it to.
|
||||||
|
//
|
||||||
|
// So the operator converts once, from their own client, and points the site at
|
||||||
|
// the result (see docs/website/CLILOCS.md). Two shapes are accepted because
|
||||||
|
// different tools produce different things:
|
||||||
|
//
|
||||||
|
// • PLAIN BINARY — the pre-compression cliloc layout: a 6-byte header, then
|
||||||
|
// records of {int32 number, byte flag, uint16 length, UTF-8 bytes}.
|
||||||
|
// • DELIMITED TEXT — `number<TAB|,|;>text` per line, which is what the common
|
||||||
|
// GUI exports emit. Quoted CSV fields and a header row are tolerated.
|
||||||
|
//
|
||||||
|
// Nothing derived from the client is ever committed: the converted file lives at
|
||||||
|
// an operator-supplied path and is gitignored, the same rule the spawn atlas art
|
||||||
|
// map already follows.
|
||||||
|
|
||||||
|
/** Raised for a file we can identify but deliberately refuse to guess at. */
|
||||||
|
class ClilocFormatError extends Error {
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ClilocFormatError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped when this parser produces DIFFERENT data from an IDENTICAL source file.
|
||||||
|
*
|
||||||
|
* Stored beside the source hash so the boot path can tell "same file, but the
|
||||||
|
* parser moved on" from "same file, nothing to do". Without it a corrected parse
|
||||||
|
* would ship and never reach an install whose cliloc file never changes — the
|
||||||
|
* trap `spawnAtlasSource.PARSER_VERSION` documents.
|
||||||
|
*/
|
||||||
|
const PARSER_VERSION = 1
|
||||||
|
|
||||||
|
// The plain layout's header is `02 00 00 00 01 00` — a 4-byte version and a
|
||||||
|
// 2-byte language marker. Only the size matters for parsing; the values are
|
||||||
|
// checked to sniff the format, not to validate it.
|
||||||
|
const HEADER_BYTES = 6
|
||||||
|
const RECORD_HEADER_BYTES = 7 // int32 number + byte flag + uint16 length
|
||||||
|
|
||||||
|
// Every compressed cliloc file the client ships begins with a DWORD whose high
|
||||||
|
// byte is 0x8E (the XOR key UOFiddler calls `HeaderXorKey`, 0x8E2C9A3D). That is
|
||||||
|
// the single cheapest way to tell an operator they exported the wrong file —
|
||||||
|
// without it, the plain parser happily reads compressed bytes as ~19k records of
|
||||||
|
// negative ids and 60 KB "strings" before dying somewhere in the middle, and the
|
||||||
|
// resulting error names the wrong problem.
|
||||||
|
const MYTHIC_HIGH_BYTE = 0x8e
|
||||||
|
|
||||||
|
/** True when `buffer` is a Mythic-compressed cliloc rather than the plain layout. */
|
||||||
|
function isCompressedCliloc(buffer) {
|
||||||
|
return buffer.length >= 4 && buffer[3] === MYTHIC_HIGH_BYTE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the plain binary cliloc layout.
|
||||||
|
*
|
||||||
|
* Strict about truncation, and that strictness is load-bearing: a half-copied or
|
||||||
|
* partly-written file is the realistic failure here, and it must fail loudly
|
||||||
|
* rather than import a silently short table that then renders half the world as
|
||||||
|
* `id 1023721`. A record that runs past the end of the buffer throws.
|
||||||
|
*/
|
||||||
|
function parseClilocBinary(buffer) {
|
||||||
|
if (!Buffer.isBuffer(buffer)) throw new ClilocFormatError('Not a buffer', 'NOT_BUFFER')
|
||||||
|
if (isCompressedCliloc(buffer)) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
||||||
|
'Convert it to the plain format first — see docs/website/CLILOCS.md.',
|
||||||
|
'COMPRESSED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (buffer.length < HEADER_BYTES) {
|
||||||
|
throw new ClilocFormatError('File is shorter than a cliloc header', 'TRUNCATED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
let offset = HEADER_BYTES
|
||||||
|
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
if (offset + RECORD_HEADER_BYTES > buffer.length) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
`Truncated record header at byte ${offset} (${entries.length} entries read)`,
|
||||||
|
'TRUNCATED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const number = buffer.readInt32LE(offset)
|
||||||
|
const flag = buffer.readUInt8(offset + 4)
|
||||||
|
// The length is written by the client as an unsigned 16-bit value. Reading it
|
||||||
|
// signed (as ServUO's own SDK does) turns any string over 32 KB into a
|
||||||
|
// negative length; real tables top out around 12 KB, so this has no effect on
|
||||||
|
// current data and costs nothing to get right.
|
||||||
|
const length = buffer.readUInt16LE(offset + 5)
|
||||||
|
offset += RECORD_HEADER_BYTES
|
||||||
|
|
||||||
|
if (offset + length > buffer.length) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
`Truncated record body at byte ${offset} (${entries.length} entries read)`,
|
||||||
|
'TRUNCATED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entries.push({ number, flag, text: buffer.toString('utf8', offset, offset + length) })
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delimited line splits on the FIRST separator only: cliloc text is full of
|
||||||
|
// commas ("a scroll of magery, unfinished") and splitting on all of them would
|
||||||
|
// truncate every such entry at its first comma.
|
||||||
|
const TEXT_SEPARATORS = ['\t', ',', ';']
|
||||||
|
|
||||||
|
/** Unwrap one CSV field: strip surrounding quotes and unescape doubled quotes. */
|
||||||
|
function unquote(value) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"')
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a delimited text export: `number<sep>text` per line.
|
||||||
|
*
|
||||||
|
* Tolerant by design — this is whatever an operator's GUI tool produced, not a
|
||||||
|
* format we control. A header row, blank lines, `#` comments and a trailing
|
||||||
|
* flags column are all ignored. A line whose first field is not an integer is
|
||||||
|
* skipped rather than fatal, because that is exactly what a header row is.
|
||||||
|
*
|
||||||
|
* The one thing it will NOT do is return an empty table quietly: a file that
|
||||||
|
* yields no entries at all is a wrong file, not an empty one.
|
||||||
|
*/
|
||||||
|
function parseClilocText(text) {
|
||||||
|
const entries = []
|
||||||
|
for (const line of String(text).split(/\r?\n/)) {
|
||||||
|
// The line is deliberately NOT trimmed before the separator search. Roughly
|
||||||
|
// half of a real cliloc table is empty strings (unused ids), which export as
|
||||||
|
// `1005008<TAB>` — and trimming eats that trailing separator, leaving a bare
|
||||||
|
// number that then looks like a header row and is skipped. That silently
|
||||||
|
// dropped 55,994 of 123,490 entries. Individual FIELDS are trimmed instead,
|
||||||
|
// by `unquote`.
|
||||||
|
if (line.trim() === '' || line.trimStart().startsWith('#')) continue
|
||||||
|
|
||||||
|
// Pick the separator that actually appears first, so a tab-delimited line
|
||||||
|
// whose text contains a comma still splits on the tab.
|
||||||
|
let cut = -1
|
||||||
|
for (const sep of TEXT_SEPARATORS) {
|
||||||
|
const at = line.indexOf(sep)
|
||||||
|
if (at !== -1 && (cut === -1 || at < cut)) cut = at
|
||||||
|
}
|
||||||
|
if (cut === -1) continue
|
||||||
|
|
||||||
|
// An EMPTY first field must not become id 0: `Number('')` is 0, not NaN, so
|
||||||
|
// a line that merely starts with a separator would otherwise import as a
|
||||||
|
// bogus cliloc 0 instead of being skipped.
|
||||||
|
const head = unquote(line.slice(0, cut))
|
||||||
|
if (head === '') continue
|
||||||
|
const number = Number(head)
|
||||||
|
if (!Number.isInteger(number)) continue // header row, or a wrapped line
|
||||||
|
|
||||||
|
let rest = line.slice(cut + 1)
|
||||||
|
// Some exports carry `number,flag,text`. A bare integer in the second field
|
||||||
|
// is a flag; anything else is the text itself (and a text field that IS just
|
||||||
|
// a number is indistinguishable, so it stays as the text — the safer miss).
|
||||||
|
let flag = 0
|
||||||
|
for (const sep of TEXT_SEPARATORS) {
|
||||||
|
const at = rest.indexOf(sep)
|
||||||
|
if (at === -1) continue
|
||||||
|
const head = unquote(rest.slice(0, at))
|
||||||
|
if (/^\d{1,3}$/.test(head) && rest.slice(at + 1).trim() !== '') {
|
||||||
|
flag = Number(head)
|
||||||
|
rest = rest.slice(at + 1)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({ number, flag, text: unquote(rest) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new ClilocFormatError('No cliloc entries found in the text export', 'EMPTY')
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse either supported shape, sniffing which one this is.
|
||||||
|
*
|
||||||
|
* The sniff is on the binary header rather than the file extension: operators
|
||||||
|
* name these things whatever they like, and an `.enu` that is really a TSV (or a
|
||||||
|
* `.txt` that is really binary) should still import.
|
||||||
|
*/
|
||||||
|
function parseCliloc(buffer) {
|
||||||
|
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)
|
||||||
|
|
||||||
|
if (isCompressedCliloc(buf)) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
||||||
|
'Convert it to the plain format first — see docs/website/CLILOCS.md.',
|
||||||
|
'COMPRESSED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plain layout always opens with version 2 / language 1. Anything else is
|
||||||
|
// treated as text, which is the recoverable guess: a mis-sniffed text file
|
||||||
|
// yields "no entries found", while a mis-sniffed binary yields nonsense.
|
||||||
|
if (buf.length >= HEADER_BYTES && buf.readInt32LE(0) === 2 && buf.readUInt16LE(4) === 1) {
|
||||||
|
return parseClilocBinary(buf)
|
||||||
|
}
|
||||||
|
return parseClilocText(buf.toString('utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Cliloc strings interpolate arguments the client supplies out of an item's
|
||||||
|
// property list: `~1_val~`, `~2_NAME~`, `~1_ITEM~`. We never have those — the
|
||||||
|
// bridge sends the id, not the packet — so a name carrying them must be reduced
|
||||||
|
// to what is actually knowable rather than shown with the raw tokens in it.
|
||||||
|
const PLACEHOLDER_RE = /~\d+_[^~]*~/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce a raw cliloc string to something displayable.
|
||||||
|
*
|
||||||
|
* Placeholders are dropped and the leftover punctuation tidied, so
|
||||||
|
* `"[~1_stuff~]"` becomes `""` (correctly nothing — the whole string was the
|
||||||
|
* argument) and `"cold damage ~1_val~%"` becomes `"cold damage"`.
|
||||||
|
*
|
||||||
|
* **Punctuation is only tidied when a placeholder was actually removed.** The
|
||||||
|
* trailing `%` above is the unit belonging to the number we never had, and the
|
||||||
|
* brackets in `[~1_stuff~]` only ever wrapped the argument — but a string with
|
||||||
|
* no placeholder has no such debris, and trimming it anyway corrupts real names.
|
||||||
|
* A shard's `"Runic Gateway Sigil (v2)"` came back as `"(v2"` while this was
|
||||||
|
* unconditional.
|
||||||
|
*
|
||||||
|
* Returns `''` when nothing survives, which callers treat as "no name" and fall
|
||||||
|
* back to the item id — better than showing a bracket.
|
||||||
|
*/
|
||||||
|
const DEBRIS = /^[\s\-–—,.;:%[\]()]+|[\s\-–—,.;:%[\]()]+$/g
|
||||||
|
|
||||||
|
function displayText(raw) {
|
||||||
|
if (raw == null) return ''
|
||||||
|
const source = String(raw)
|
||||||
|
const hadPlaceholder = PLACEHOLDER_RE.test(source)
|
||||||
|
PLACEHOLDER_RE.lastIndex = 0 // the regex is global; `test` advances it
|
||||||
|
|
||||||
|
if (!hadPlaceholder) return source.replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
|
return source
|
||||||
|
.replace(PLACEHOLDER_RE, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\s+([,.;:!?])/g, '$1')
|
||||||
|
.replace(DEBRIS, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a raw cliloc string is nothing but interpolated arguments. */
|
||||||
|
const isPlaceholderOnly = (raw) => raw != null && String(raw).trim() !== '' && displayText(raw) === ''
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ClilocFormatError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
HEADER_BYTES,
|
||||||
|
isCompressedCliloc,
|
||||||
|
parseCliloc,
|
||||||
|
parseClilocBinary,
|
||||||
|
parseClilocText,
|
||||||
|
displayText,
|
||||||
|
isPlaceholderOnly,
|
||||||
|
}
|
||||||
316
server/utils/clilocSource.js
Normal file
316
server/utils/clilocSource.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// Cliloc table — the filesystem layer.
|
||||||
|
//
|
||||||
|
// `clilocParse.js` holds the pure parsers; this module is the only thing that
|
||||||
|
// touches cliloc files on disk, and it is shared by both callers:
|
||||||
|
//
|
||||||
|
// - the server, which refreshes the table on boot (`shardClilocs.model.js`)
|
||||||
|
// - the admin panel, which can force a reimport without a restart
|
||||||
|
//
|
||||||
|
// The files are the OPERATOR'S (see docs/website/CLILOCS.md). Nothing derived
|
||||||
|
// from them is committed: the repo holds no string table, exactly as it holds no
|
||||||
|
// map snapshot and no artwork. That rule is why this module reads a configured
|
||||||
|
// path instead of a path inside the repo.
|
||||||
|
//
|
||||||
|
// ── Why this reads a SET of files, not one ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shards edit items and add new ones. Those carry cliloc ids that a stock client
|
||||||
|
// table does not have — and forcing a 5 MB client re-export every time an
|
||||||
|
// operator adds one item would be miserable enough that the table would simply
|
||||||
|
// go stale, which is the exact failure the spawn atlas was redesigned to avoid.
|
||||||
|
//
|
||||||
|
// So this mirrors `spawnAtlasSource.readSources()`: a BASE table (the converted
|
||||||
|
// client file) plus every operator-maintained OVERLAY beside it, all re-read on
|
||||||
|
// every boot and hash-gated as a SET. Adding, editing or removing any overlay
|
||||||
|
// counts as drift and re-imports. Later sources win, so an overlay both adds new
|
||||||
|
// ids and overrides stock ones.
|
||||||
|
//
|
||||||
|
// Measured on a real shard: the script tree references 16,434 cliloc ids and only
|
||||||
|
// 37 are absent from the stock client table. Tens of entries against a 67k base
|
||||||
|
// is what makes the overlay the right shape rather than a second full table.
|
||||||
|
//
|
||||||
|
// Reading and hashing ~5 MB costs a few milliseconds and a full parse ~50 ms, so
|
||||||
|
// the boot path hashes first and only parses when something actually changed.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { ClilocFormatError, PARSER_VERSION, parseCliloc, isCompressedCliloc } = require('./clilocParse')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filenames looked for as the BASE table when the configured path is a directory.
|
||||||
|
*
|
||||||
|
* Ordered by how specific they are: an explicitly converted file wins over
|
||||||
|
* something that merely sits in a client folder, so an operator who dropped a
|
||||||
|
* `cliloc.plain.enu` next to the original compressed `cliloc.enu` gets the one
|
||||||
|
* they made rather than the one that will be rejected.
|
||||||
|
*
|
||||||
|
* Matching is case-insensitive against the real directory listing, because the
|
||||||
|
* client ships `Cliloc.enu` on Windows and the site usually runs on Linux, where
|
||||||
|
* a hardcoded lowercase open would simply miss.
|
||||||
|
*/
|
||||||
|
const CANDIDATE_NAMES = [
|
||||||
|
'clilocs.tsv',
|
||||||
|
'clilocs.csv',
|
||||||
|
'clilocs.plain',
|
||||||
|
'cliloc.plain',
|
||||||
|
'cliloc.plain.enu',
|
||||||
|
'cliloc.enu.plain',
|
||||||
|
'clilocs.txt',
|
||||||
|
'cliloc.enu',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where shard-specific additions and overrides live: a `custom/` directory
|
||||||
|
* beside the base table.
|
||||||
|
*
|
||||||
|
* ServUO has **no server-side convention** for custom clilocs — they live in the
|
||||||
|
* patched client file a shard distributes to its players, and nothing in the
|
||||||
|
* tree declares them. There is therefore nothing to discover, and this is the
|
||||||
|
* one place in the cliloc pipeline that is a convention we chose rather than one
|
||||||
|
* the shard already has. It is a directory rather than a single file so an
|
||||||
|
* operator can keep additions grouped however they like (per system, per patch)
|
||||||
|
* without the site caring.
|
||||||
|
*/
|
||||||
|
const CUSTOM_DIR = 'custom'
|
||||||
|
const CUSTOM_EXTENSIONS = ['.tsv', '.csv', '.txt', '.enu', '.plain']
|
||||||
|
|
||||||
|
class ClilocSourceError extends Error {
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ClilocSourceError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return crypto.createHash('sha256').update(buffer).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the configured path to `{ root, base }`.
|
||||||
|
*
|
||||||
|
* Accepts either a direct file path or a directory to search, because operators
|
||||||
|
* reasonably supply both — "here is the file" and "here is the folder I put it
|
||||||
|
* in" are equally natural answers to the admin panel's prompt. When it is a
|
||||||
|
* file, `root` is the directory CONTAINING it, so overlays work either way: an
|
||||||
|
* operator who pointed at a file should not have to re-point at its folder just
|
||||||
|
* to add a `custom/` directory next to it.
|
||||||
|
*/
|
||||||
|
function resolveBase(configured) {
|
||||||
|
if (!configured || String(configured).trim() === '') {
|
||||||
|
throw new ClilocSourceError('No cliloc path configured', 'NO_PATH')
|
||||||
|
}
|
||||||
|
const target = String(configured).trim()
|
||||||
|
|
||||||
|
let stat
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(target)
|
||||||
|
} catch {
|
||||||
|
throw new ClilocSourceError(`Cliloc path does not exist: ${target}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isFile()) return { root: path.dirname(target), base: target }
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new ClilocSourceError(`Cliloc path is neither a file nor a directory: ${target}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
let listing
|
||||||
|
try {
|
||||||
|
listing = fs.readdirSync(target)
|
||||||
|
} catch {
|
||||||
|
throw new ClilocSourceError(`Cliloc directory is not readable: ${target}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
const byLower = new Map(listing.map((name) => [name.toLowerCase(), name]))
|
||||||
|
for (const candidate of CANDIDATE_NAMES) {
|
||||||
|
const actual = byLower.get(candidate)
|
||||||
|
if (actual) return { root: target, base: path.join(target, actual) }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ClilocSourceError(
|
||||||
|
`No cliloc file found in ${target} (looked for ${CANDIDATE_NAMES.join(', ')})`,
|
||||||
|
'NO_FILE',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlay files under `<root>/custom/`, sorted so precedence is deterministic. */
|
||||||
|
function listCustom(root) {
|
||||||
|
const dir = path.join(root, CUSTOM_DIR)
|
||||||
|
let listing
|
||||||
|
try {
|
||||||
|
listing = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch (err) {
|
||||||
|
// No overlay directory is the normal case, not an error.
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||||
|
throw new ClilocSourceError(`Cliloc overlay directory is not readable: ${dir}`, 'UNREADABLE')
|
||||||
|
}
|
||||||
|
return listing
|
||||||
|
.filter((e) => e.isFile() && CUSTOM_EXTENSIONS.includes(path.extname(e.name).toLowerCase()))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort()
|
||||||
|
.map((name) => path.join(dir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileOrThrow(file) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file)
|
||||||
|
} catch {
|
||||||
|
throw new ClilocSourceError(`Cliloc file is not readable: ${file}`, 'UNREADABLE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every cliloc source under the configured path.
|
||||||
|
*
|
||||||
|
* Returns `{ root, files: [{ label, kind, file, buffer, sha256, bytes, compressed }] }`
|
||||||
|
* with the base first and overlays after, in the order they must be merged.
|
||||||
|
*
|
||||||
|
* Labels are root-relative and forward-slashed so a hash map compares equal
|
||||||
|
* across platforms — the same directory read on Windows and Linux must produce
|
||||||
|
* the same fingerprint, or every boot would look like a change. (The same
|
||||||
|
* reasoning, and the same bug, as `spawnAtlasSource.readSources`.)
|
||||||
|
*/
|
||||||
|
function readSources(configured) {
|
||||||
|
const { root, base } = resolveBase(configured)
|
||||||
|
|
||||||
|
const describe = (file, kind) => {
|
||||||
|
const buffer = readFileOrThrow(file)
|
||||||
|
return {
|
||||||
|
label: path.relative(root, file).split(path.sep).join('/'),
|
||||||
|
kind,
|
||||||
|
file,
|
||||||
|
buffer,
|
||||||
|
sha256: sha256(buffer),
|
||||||
|
bytes: buffer.length,
|
||||||
|
compressed: isCompressedCliloc(buffer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [describe(base, 'base')]
|
||||||
|
for (const overlay of listCustom(root)) files.push(describe(overlay, 'custom'))
|
||||||
|
|
||||||
|
return { root, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fingerprint of every source: `{ "<label>": "<sha256>" }`, plus the base's
|
||||||
|
* details for the admin panel.
|
||||||
|
*
|
||||||
|
* `compressed` is reported here rather than left to the parse because the admin
|
||||||
|
* panel calls this and NOT `readCliloc` (parsing 5 MB on every status poll would
|
||||||
|
* be wasteful). Without it, pointing the setting at an unconverted client
|
||||||
|
* directory reports a perfectly readable file with pending drift — "ready to
|
||||||
|
* import" — and the operator only learns otherwise when the import fails. The
|
||||||
|
* check is four bytes of a buffer already in hand.
|
||||||
|
*/
|
||||||
|
function hashSources(configured) {
|
||||||
|
const { root, files } = readSources(configured)
|
||||||
|
const hashes = {}
|
||||||
|
for (const file of files) hashes[file.label] = file.sha256
|
||||||
|
const base = files[0]
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
hashes,
|
||||||
|
file: base.file,
|
||||||
|
bytes: base.bytes,
|
||||||
|
compressed: files.some((f) => f.compressed),
|
||||||
|
customCount: files.length - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when two source fingerprints describe the same set of files. */
|
||||||
|
function sameSources(a, b) {
|
||||||
|
if (!a || !b) return false
|
||||||
|
const aKeys = Object.keys(a).sort()
|
||||||
|
const bKeys = Object.keys(b).sort()
|
||||||
|
if (aKeys.length !== bKeys.length) return false
|
||||||
|
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Labels present in `loaded` that are absent from `current`.
|
||||||
|
*
|
||||||
|
* This is the multi-source hazard that a single file did not have. One corrupt
|
||||||
|
* file fails the parse loudly, but a source that has simply VANISHED — an
|
||||||
|
* unmounted volume, a half-copied deploy — parses perfectly and imports a table
|
||||||
|
* quietly missing everything that file contributed. That is the same ambiguity
|
||||||
|
* the spawn atlas escalates for a disappearing facet, so it is escalated here
|
||||||
|
* too rather than applied.
|
||||||
|
*/
|
||||||
|
function missingSources(current, loaded) {
|
||||||
|
if (!loaded) return []
|
||||||
|
return Object.keys(loaded).filter((label) => !Object.hasOwn(current, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse every source, merged into one entry list.
|
||||||
|
*
|
||||||
|
* Later sources win: the base client table first, then each overlay in sorted
|
||||||
|
* order, so an overlay both ADDS ids the client never had and OVERRIDES stock
|
||||||
|
* ones the shard has re-purposed.
|
||||||
|
*
|
||||||
|
* Returns `{ entries, source }`. Throws `ClilocSourceError` for anything about
|
||||||
|
* the paths and `ClilocFormatError` for anything about the contents — different
|
||||||
|
* problems for an operator (wrong place vs wrong file), and the admin panel says
|
||||||
|
* which. A format error names the file it came from, because "which of my six
|
||||||
|
* overlay files is malformed" is otherwise a guessing game.
|
||||||
|
*/
|
||||||
|
function readCliloc(configured) {
|
||||||
|
const { root, files } = readSources(configured)
|
||||||
|
|
||||||
|
const merged = new Map()
|
||||||
|
const perSource = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = parseCliloc(file.buffer)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ClilocFormatError) {
|
||||||
|
throw new ClilocFormatError(`${file.label}: ${err.message}`, err.code)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = 0
|
||||||
|
let overrode = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!Number.isInteger(entry.number)) continue
|
||||||
|
if (merged.has(entry.number)) overrode++
|
||||||
|
else added++
|
||||||
|
merged.set(entry.number, entry)
|
||||||
|
}
|
||||||
|
perSource.push({ label: file.label, kind: file.kind, entries: entries.length, added, overrode })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: [...merged.values()],
|
||||||
|
source: {
|
||||||
|
root,
|
||||||
|
file: files[0].file,
|
||||||
|
sha256: files[0].sha256,
|
||||||
|
bytes: files[0].bytes,
|
||||||
|
hashes: Object.fromEntries(files.map((f) => [f.label, f.sha256])),
|
||||||
|
parserVersion: PARSER_VERSION,
|
||||||
|
sources: perSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ClilocFormatError,
|
||||||
|
ClilocSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
CANDIDATE_NAMES,
|
||||||
|
CUSTOM_DIR,
|
||||||
|
CUSTOM_EXTENSIONS,
|
||||||
|
resolveBase,
|
||||||
|
listCustom,
|
||||||
|
readSources,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
missingSources,
|
||||||
|
readCliloc,
|
||||||
|
}
|
||||||
33
server/utils/excerpt.js
Normal file
33
server/utils/excerpt.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// A plain-text excerpt of a post body, for the town crier and the news gump.
|
||||||
|
//
|
||||||
|
// **Vendored from core's `utils/sanitizeHtml.js`, deliberately, and it is worth
|
||||||
|
// being precise about what was and was not copied.** Core's file exports three
|
||||||
|
// things: `cleanBody` (the actual HTML sanitiser, backed by a dependency and a
|
||||||
|
// tag allowlist), `OPTIONS`, and this. Only this one came, because only this one
|
||||||
|
// is a pure function over a string with no security surface — it strips tags to
|
||||||
|
// get at the text, it does not decide what tags are safe to render.
|
||||||
|
//
|
||||||
|
// Copying the sanitiser would have been the wrong call for exactly the reason
|
||||||
|
// this comment exists: a second copy of a security control diverges from the
|
||||||
|
// first the moment either is fixed, and the divergence is silent. A module that
|
||||||
|
// needs to sanitise HTML for rendering should ask core for it. This one does
|
||||||
|
// not — its output goes into a game window and a chat message as text.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten HTML to a single line of text, truncated with an ellipsis.
|
||||||
|
*
|
||||||
|
* @param {string|null} html
|
||||||
|
* @param {number} max characters, including the ellipsis
|
||||||
|
* @returns {string|null} null when there is nothing left after stripping
|
||||||
|
*/
|
||||||
|
function deriveExcerpt(html, max = 280) {
|
||||||
|
if (html == null) return null
|
||||||
|
const text = String(html)
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
if (!text) return null
|
||||||
|
return text.length > max ? `${text.slice(0, max - 3)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { deriveExcerpt }
|
||||||
123
server/utils/newsGump.js
Normal file
123
server/utils/newsGump.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// ── Town Cryer News gump sync (Protocol 2.1) ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Keeps the in-game Town Cryer *News* gump in sync with the site's published
|
||||||
|
// news posts. Distinct from the scrolling town-crier lines (that's a one-shot
|
||||||
|
// announce leg in announceWorker); this is a STATE SYNC — an article stays in the
|
||||||
|
// gump while its post is published news, and is pulled when the post is
|
||||||
|
// unpublished/deleted/re-categorised.
|
||||||
|
//
|
||||||
|
// The website is the source of truth. POST /news is idempotent (re-post replaces),
|
||||||
|
// so a refresh or a reconnect re-assert is safe. Every call is best-effort and
|
||||||
|
// never throws — a sidecar/shard hiccup must never break saving or deleting a
|
||||||
|
// post. Reliability comes from reassertAll() on every WS (re)connect
|
||||||
|
// (uoLinkSocket.backfill), which re-pushes the current published set silently and
|
||||||
|
// closes the gap if an earlier live push failed.
|
||||||
|
|
||||||
|
const { posts, settings } = require('../core')
|
||||||
|
const uoLinkClient = require('./uoLinkClient')
|
||||||
|
const { deriveExcerpt } = require('./excerpt')
|
||||||
|
const log = require('../core').logger('news-gump')
|
||||||
|
|
||||||
|
const MAX_TITLE = 120
|
||||||
|
const MAX_BODY = 900
|
||||||
|
|
||||||
|
function baseUrl() {
|
||||||
|
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value, max) {
|
||||||
|
const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
|
||||||
|
return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A post belongs in the gump exactly when it is published AND in the news category.
|
||||||
|
function inGump(post) {
|
||||||
|
return Boolean(post && post.published && post.category === 'news')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional UO gump image id for news articles (a shard art id), from the
|
||||||
|
// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll.
|
||||||
|
async function gumpImage() {
|
||||||
|
try {
|
||||||
|
const raw = await settings.get('news_gump_image')
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the in-game News article from a post. Body is a compact gump-HTML block
|
||||||
|
// (title centred + a plain-text excerpt) rather than the post's full rich HTML —
|
||||||
|
// the UO gump only supports a small HTML subset, so we keep it predictable. The
|
||||||
|
// "more info" URL is the public news list (news posts have no per-post route).
|
||||||
|
async function buildArticle(post, { announce = true } = {}) {
|
||||||
|
const title = clamp(post.title, MAX_TITLE)
|
||||||
|
const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY)
|
||||||
|
const body = excerpt ? `<CENTER>${title}</CENTER><BR><BR>${excerpt}` : `<CENTER>${title}</CENTER>`
|
||||||
|
return {
|
||||||
|
id: String(post.id),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
image: await gumpImage(),
|
||||||
|
url: `${baseUrl()}/site/news`,
|
||||||
|
announce,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a post to the gump (only if it belongs there). announce=true has the criers
|
||||||
|
// proclaim the title; false is a silent refresh/re-assert.
|
||||||
|
async function pushPost(post, { announce = true } = {}) {
|
||||||
|
if (!inGump(post)) return { ok: false, skipped: true }
|
||||||
|
const res = await uoLinkClient.postNews(await buildArticle(post, { announce }))
|
||||||
|
if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error })
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a post from the gump. A 404 (not present) is not an error worth noting.
|
||||||
|
async function removePost(id) {
|
||||||
|
const res = await uoLinkClient.deleteNews(String(id))
|
||||||
|
if (!res.ok && res.status !== 404) {
|
||||||
|
log.warn('news gump remove failed', { id, status: res.status, error: res.error })
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the gump after a post create/update/publish. `transition`
|
||||||
|
// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place
|
||||||
|
// edit (silent refresh) and catches a post leaving published-news (pull it).
|
||||||
|
async function syncPost(post, transition = {}) {
|
||||||
|
try {
|
||||||
|
if (inGump(post)) {
|
||||||
|
const wasInGump = Boolean(transition.wasPublished && transition.wasNews)
|
||||||
|
await pushPost(post, { announce: !wasInGump })
|
||||||
|
} else if (transition.wasPublished && transition.wasNews) {
|
||||||
|
await removePost(post.id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('news gump sync failed', { id: post && post.id, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-push every currently-published news post, silently — run on each WS
|
||||||
|
// (re)connect to reconcile the gump to our source of truth (also recovers any
|
||||||
|
// article whose original live push failed). Best-effort; never throws.
|
||||||
|
async function reassertAll() {
|
||||||
|
try {
|
||||||
|
const list = await posts.listAll('news')
|
||||||
|
const published = (list || []).filter((p) => p.published)
|
||||||
|
let pushed = 0
|
||||||
|
for (const p of published) {
|
||||||
|
const full = await posts.getById(p.id) // list projection may omit the body
|
||||||
|
if (full) {
|
||||||
|
await pushPost(full, { announce: false })
|
||||||
|
pushed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pushed) log.info('re-asserted news gump articles', { count: pushed })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('news gump reassert failed', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll }
|
||||||
77
server/utils/shardAnnounce.js
Normal file
77
server/utils/shardAnnounce.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// ── The in-game town-crier announce leg ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8's third
|
||||||
|
// entangled file: utils/announceWorker.js is core's news dispatcher, but one of
|
||||||
|
// its two delivery legs goes to the shard through uoLinkClient.postTownCrier.
|
||||||
|
// PR 4 turned the legs into registrations, and this file is what module-uo will
|
||||||
|
// register in Phase 3 — it moves whole, with `'core'` becoming `'uo'` and the
|
||||||
|
// leg id staying `towncrier` (grandfathered in registries.js: the id is a stored
|
||||||
|
// value in announce_job_legs.leg).
|
||||||
|
|
||||||
|
const uoLinkClient = require('./uoLinkClient')
|
||||||
|
const { deriveExcerpt } = require('./excerpt')
|
||||||
|
const { articleUrl, baseUrl, legError } = require('./announceLinks')
|
||||||
|
|
||||||
|
const TOWNCRIER_DURATION_SEC = Number(process.env.TOWNCRIER_DURATION_SEC) || 3600
|
||||||
|
|
||||||
|
// Sidecar town-crier caps, mirrored from the admin route validation
|
||||||
|
// (admin/uoLink.router.js: lines isArray({ max: 8 }), lines.* isLength({ max: 200 })).
|
||||||
|
// We pre-truncate to these so a published post never bounces with an error.
|
||||||
|
const MAX_LINES = 8
|
||||||
|
const MAX_LINE_LEN = 200
|
||||||
|
|
||||||
|
// Trim to a hard length, appending an ellipsis only when something was cut.
|
||||||
|
function clamp(value, max) {
|
||||||
|
const s = String(value == null ? '' : value)
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
if (s.length <= max) return s
|
||||||
|
return `${s.slice(0, max - 1).trimEnd()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the town-crier lines: title, a one-line excerpt, then the URL. Each line
|
||||||
|
// is clamped to the sidecar's per-line cap and the whole thing to the line-count
|
||||||
|
// cap. Falls back to a stripped body excerpt when the post has no excerpt.
|
||||||
|
function buildTownCrierText(post, { baseUrl: base } = {}) {
|
||||||
|
const title = clamp(post.title, MAX_LINE_LEN)
|
||||||
|
const excerptSource = post.excerpt || deriveExcerpt(post.body, MAX_LINE_LEN) || ''
|
||||||
|
const lines = [title]
|
||||||
|
const excerpt = clamp(excerptSource, MAX_LINE_LEN)
|
||||||
|
if (excerpt) lines.push(excerpt)
|
||||||
|
const url = clamp(articleUrl(base), MAX_LINE_LEN)
|
||||||
|
if (url) lines.push(url)
|
||||||
|
return lines.filter(Boolean).slice(0, MAX_LINES)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatch(post) {
|
||||||
|
const lines = buildTownCrierText(post, { baseUrl: baseUrl() })
|
||||||
|
// Stable id: re-posting `post-<id>` REPLACES the prior town-crier entry rather
|
||||||
|
// than stacking a duplicate, so a retry after a partial failure is safe.
|
||||||
|
return uoLinkClient.postTownCrier({
|
||||||
|
id: `post-${post.id}`,
|
||||||
|
lines,
|
||||||
|
durationSec: TOWNCRIER_DURATION_SEC,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(result) {
|
||||||
|
if (result && result.ok) return { outcome: 'done' }
|
||||||
|
const status = result ? result.status : 0
|
||||||
|
// 400 = over the line/duration caps (a data problem — do NOT retry).
|
||||||
|
// 401 = token mismatch, 409 = protocol mismatch (both config problems).
|
||||||
|
if (status === 400 || status === 401 || status === 409) {
|
||||||
|
return { outcome: 'terminal', error: legError(result) }
|
||||||
|
}
|
||||||
|
// 503 (shard not connected), 504 (shard timeout), 0 (network/timeout / not
|
||||||
|
// configured yet), and any other 5xx are transient — retry.
|
||||||
|
return { outcome: 'retry', error: legError(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const leg = {
|
||||||
|
leg: 'towncrier',
|
||||||
|
label: 'In-game town crier',
|
||||||
|
dispatch,
|
||||||
|
classify,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { leg, dispatch, classify, buildTownCrierText, MAX_LINES, MAX_LINE_LEN }
|
||||||
166
server/utils/shardBroadcast.js
Normal file
166
server/utils/shardBroadcast.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
||||||
|
// never reach it, and the WS may be on another host). Instead the server ingests
|
||||||
|
// the WS feed and re-broadcasts events to browsers over Server-Sent Events
|
||||||
|
// (plain HTTP — works through any reverse proxy).
|
||||||
|
//
|
||||||
|
// Since Protocol 3.0 the split is no longer "one public channel with a static
|
||||||
|
// allowlist plus one admin channel". Each subscriber carries the audience rung
|
||||||
|
// it resolved to at subscribe time, and every frame is
|
||||||
|
//
|
||||||
|
// 1. mapped kind → feature (an UNMAPPED kind reaches nobody below admin —
|
||||||
|
// fail closed; see utils/shardVisibility.js rule 2),
|
||||||
|
// 2. gated on that feature being enabled, streamed, and within the viewer's
|
||||||
|
// rung, and
|
||||||
|
// 3. passed through field projection, so `acct` / `webId` and any field an
|
||||||
|
// admin has re-gated are stripped per viewer.
|
||||||
|
//
|
||||||
|
// **This is the security boundary.** It used to be the PUBLIC_KINDS set in this
|
||||||
|
// file; it is now the kind map plus the visibility config. PUBLIC_KINDS still
|
||||||
|
// exists and is still exported, but it is now DERIVED from the kind map (see
|
||||||
|
// shardVisibility.js) so the two can no longer drift.
|
||||||
|
//
|
||||||
|
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||||
|
// SSE route handlers call subscribe(req, res, channel).
|
||||||
|
|
||||||
|
const visibility = require('./shardVisibility')
|
||||||
|
const log = require('../core').logger('shard-broadcast')
|
||||||
|
|
||||||
|
// Re-exported for back-compat: shardEvents `/feed` filtering and
|
||||||
|
// config/notificationStreams.js both ask "is this kind public-safe?".
|
||||||
|
const { PUBLIC_KINDS } = visibility
|
||||||
|
|
||||||
|
// Open streams. Each entry is { res, level }. The admin bucket is kept separate
|
||||||
|
// because it is unconditional and must not depend on a config read.
|
||||||
|
const clients = { public: new Set(), admin: new Set() }
|
||||||
|
|
||||||
|
const KEEPALIVE_MS = 25000
|
||||||
|
|
||||||
|
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
||||||
|
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
||||||
|
//
|
||||||
|
// The viewer's rung is resolved ONCE, here, and frozen for the life of the
|
||||||
|
// connection — a long-lived stream must not silently gain privilege because the
|
||||||
|
// caller's session changed underneath it. (Config changes, by contrast, DO take
|
||||||
|
// effect live: the config is read per broadcast, cached ~5s.)
|
||||||
|
async function subscribe(req, res, channel) {
|
||||||
|
const bucket = clients[channel]
|
||||||
|
if (!bucket) {
|
||||||
|
res.status(400).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = 'admin'
|
||||||
|
if (channel === 'public') {
|
||||||
|
try {
|
||||||
|
level = await visibility.viewerLevel(req)
|
||||||
|
} catch (err) {
|
||||||
|
// Fail closed: an unresolvable viewer is anonymous, not privileged.
|
||||||
|
log.warn('viewerLevel failed on subscribe; treating as anonymous', { message: err.message })
|
||||||
|
level = 'anonymous'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
|
||||||
|
})
|
||||||
|
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||||
|
res.write(': connected\n\n')
|
||||||
|
|
||||||
|
const client = { res, level, ping: null }
|
||||||
|
bucket.add(client)
|
||||||
|
|
||||||
|
client.ping = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(': ping\n\n')
|
||||||
|
} catch {
|
||||||
|
/* write after close — cleanup below handles it */
|
||||||
|
}
|
||||||
|
}, KEEPALIVE_MS)
|
||||||
|
|
||||||
|
const cleanup = () => drop(bucket, client)
|
||||||
|
req.on('close', cleanup)
|
||||||
|
res.on('error', cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather
|
||||||
|
// than only in the close handler) matters: a client dropped because its write
|
||||||
|
// threw never fires `req.close`, so its interval would otherwise keep firing on
|
||||||
|
// a dead socket for the life of the process.
|
||||||
|
function drop(bucket, client) {
|
||||||
|
clearInterval(client.ping)
|
||||||
|
bucket.delete(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTo(bucket, client, payload) {
|
||||||
|
try {
|
||||||
|
client.res.write(payload)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('sse write failed; dropping client', { message: err.message })
|
||||||
|
drop(bucket, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan an ingested event out. The admin channel gets it verbatim, always. Public
|
||||||
|
// subscribers are filtered and projected per their own rung — so two viewers on
|
||||||
|
// the same channel can legitimately receive different versions of one frame, or
|
||||||
|
// one of them nothing at all.
|
||||||
|
async function broadcast(event) {
|
||||||
|
if (!event || !event.kind) return
|
||||||
|
|
||||||
|
if (clients.admin.size) {
|
||||||
|
const frame = `data: ${JSON.stringify(event)}\n\n`
|
||||||
|
for (const client of [...clients.admin]) writeTo(clients.admin, client, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clients.public.size) return
|
||||||
|
|
||||||
|
let config
|
||||||
|
try {
|
||||||
|
config = await visibility.getConfig()
|
||||||
|
} catch (err) {
|
||||||
|
// Fail closed: without a config we cannot prove a frame is safe to send.
|
||||||
|
log.error('visibility config unavailable; withholding public frame', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most frames land on one rung set, so cache the serialised payload per level
|
||||||
|
// instead of re-projecting and re-stringifying for every subscriber.
|
||||||
|
const byLevel = new Map()
|
||||||
|
for (const client of [...clients.public]) {
|
||||||
|
let frame = byLevel.get(client.level)
|
||||||
|
if (frame === undefined) {
|
||||||
|
frame = visibility.kindVisibleTo(event.kind, client.level, config)
|
||||||
|
? `data: ${JSON.stringify(visibility.projectFeature(visibility.KIND_FEATURE.get(event.kind), event, client.level, config))}\n\n`
|
||||||
|
: null
|
||||||
|
byLevel.set(client.level, frame)
|
||||||
|
}
|
||||||
|
if (frame) writeTo(clients.public, client, frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close every open stream (graceful shutdown). Clears each keepalive timer too —
|
||||||
|
// without that the intervals keep the event loop alive after the streams are
|
||||||
|
// gone, and the process won't exit.
|
||||||
|
function closeAll() {
|
||||||
|
for (const bucket of Object.values(clients)) {
|
||||||
|
for (const client of [...bucket]) {
|
||||||
|
drop(bucket, client)
|
||||||
|
try {
|
||||||
|
client.res.end()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stats() {
|
||||||
|
return { publicClients: clients.public.size, adminClients: clients.admin.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
|
||||||
314
server/utils/shardIngest.js
Normal file
314
server/utils/shardIngest.js
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
// ── Shard event ingest dispatcher ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The single entry point for every event that arrives on the uo-link WebSocket
|
||||||
|
// feed (and for backfilled /history events on reconnect). It routes by kind:
|
||||||
|
// • state-changing kinds update shard_online / shard_economy / shard_houses,
|
||||||
|
// • notable kinds are appended to the append-only shard_events log,
|
||||||
|
// • every kind is fanned out to the SSE broadcaster (which decides public vs
|
||||||
|
// admin visibility).
|
||||||
|
// High-frequency kinds (char.vitals, economy.supply) are deliberately NOT logged
|
||||||
|
// to shard_events — they only update state — keeping the event log lean.
|
||||||
|
//
|
||||||
|
// Dependencies are injected (defaulting to the real models) so the routing can
|
||||||
|
// be unit-tested with mocked writes.
|
||||||
|
|
||||||
|
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||||
|
const shardStateModel = require('../model/shardState/shardState.model')
|
||||||
|
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||||
|
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const { settings: settingsModel } = require('../core')
|
||||||
|
const broadcaster = require('./shardBroadcast')
|
||||||
|
const shardPush = require('./shardPush')
|
||||||
|
const defaultLog = require('../core').logger('shard-ingest')
|
||||||
|
|
||||||
|
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||||
|
// (char.vitals, economy.supply, mob.login/logout, account.login.attempt,
|
||||||
|
// gold.change, vendor.buy/sell) are excluded on purpose. house.decay is handled
|
||||||
|
// specially — logged only on the transition INTO IDOC.
|
||||||
|
const LOGGED_KINDS = new Set([
|
||||||
|
'vendor.sale',
|
||||||
|
'player.death',
|
||||||
|
'player.murdered',
|
||||||
|
'mob.killed',
|
||||||
|
'quest.complete',
|
||||||
|
'skill.gain',
|
||||||
|
'fame.change',
|
||||||
|
'karma.change',
|
||||||
|
'audit.set',
|
||||||
|
'audit.command',
|
||||||
|
'admin.audit',
|
||||||
|
'cheat.fastwalk',
|
||||||
|
'link.request',
|
||||||
|
'server.hello',
|
||||||
|
'server.shutdown',
|
||||||
|
'server.crashed',
|
||||||
|
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
||||||
|
'guild.join',
|
||||||
|
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
|
||||||
|
'account.audit',
|
||||||
|
'account.unlinked',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Tracks the current shard boot id so a restart (changed bootId on server.hello)
|
||||||
|
// can be detected and stale online state dropped. Module-level so it survives
|
||||||
|
// across events within a process; reset() is exposed for tests.
|
||||||
|
const state = { bootId: null }
|
||||||
|
function reset() {
|
||||||
|
state.bootId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should this event be written to the append-only log?
|
||||||
|
function shouldLog(event) {
|
||||||
|
if (event.kind === 'house.decay') return String(event.to).toUpperCase() === 'IDOC'
|
||||||
|
return LOGGED_KINDS.has(event.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServUO's stock Server.cfg name. An operator who never set one publishes this
|
||||||
|
// verbatim, so it carries no more information than a blank — matched
|
||||||
|
// case-insensitively and trim-tolerantly, but ONLY as an exact whole value: a
|
||||||
|
// shard genuinely called "My Shard Reborn" keeps its name.
|
||||||
|
const STOCK_SHARD_NAME = 'my shard'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name to publish for the shard: its own, or this instance's when it has
|
||||||
|
* effectively not given one.
|
||||||
|
*
|
||||||
|
* Deliberately not a general "blank means brand" rule applied across the wire —
|
||||||
|
* it is scoped to this one field, where the two names denote the same thing.
|
||||||
|
*/
|
||||||
|
async function resolveShardName(shard, deps) {
|
||||||
|
const given = String(shard ?? '').trim()
|
||||||
|
if (given !== '' && given.toLowerCase() !== STOCK_SHARD_NAME) return given
|
||||||
|
try {
|
||||||
|
return (await deps.settings.getInstanceName()) || given
|
||||||
|
} catch {
|
||||||
|
// A ruleset that publishes the stock name is still better than one that
|
||||||
|
// fails to store because the settings read hiccuped.
|
||||||
|
return given
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||||
|
async function applyStateChange(event, deps) {
|
||||||
|
const { shardState, uoLinkConfig, log } = deps
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'server.hello': {
|
||||||
|
const incoming = event.bootId || null
|
||||||
|
if (incoming && state.bootId && incoming !== state.bootId) {
|
||||||
|
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
||||||
|
from: state.bootId,
|
||||||
|
to: incoming,
|
||||||
|
})
|
||||||
|
await shardState.clearOnline()
|
||||||
|
}
|
||||||
|
if (incoming) state.bootId = incoming
|
||||||
|
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'server.shutdown':
|
||||||
|
case 'server.crashed':
|
||||||
|
// Shard is going away — nobody is online anymore.
|
||||||
|
await shardState.clearOnline()
|
||||||
|
await uoLinkConfig.recordStatus({ pluginConnected: false })
|
||||||
|
return
|
||||||
|
case 'mob.login': {
|
||||||
|
const who = event.who || {}
|
||||||
|
await shardState.upsertOnline({
|
||||||
|
serial: who.serial,
|
||||||
|
name: who.name,
|
||||||
|
acct: who.acct,
|
||||||
|
webId: event.webId,
|
||||||
|
map: event.map,
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
z: event.z,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'mob.logout': {
|
||||||
|
const who = event.who || {}
|
||||||
|
if (who.serial) await shardState.setOffline(who.serial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'char.vitals':
|
||||||
|
await shardState.upsertOnline({
|
||||||
|
serial: event.serial,
|
||||||
|
hits: event.hits,
|
||||||
|
hitsMax: event.hitsMax,
|
||||||
|
mana: event.mana,
|
||||||
|
manaMax: event.manaMax,
|
||||||
|
stam: event.stam,
|
||||||
|
stamMax: event.stamMax,
|
||||||
|
str: event.str,
|
||||||
|
dex: event.dex,
|
||||||
|
int: event.int,
|
||||||
|
map: event.map,
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case 'economy.supply':
|
||||||
|
await shardState.addEconomySample({ accounts: event.accounts, gold: event.gold, t: event.t })
|
||||||
|
return
|
||||||
|
case 'house.decay':
|
||||||
|
await shardState.upsertHouse({
|
||||||
|
serial: event.serial,
|
||||||
|
stage: event.to,
|
||||||
|
map: event.map,
|
||||||
|
x: event.x,
|
||||||
|
y: event.y,
|
||||||
|
z: event.z,
|
||||||
|
region: event.region,
|
||||||
|
name: event.name,
|
||||||
|
ownerSerial: event.ownerSerial,
|
||||||
|
ownerAcct: event.ownerAcct,
|
||||||
|
builtOn: event.builtOn,
|
||||||
|
lastRefreshed: event.lastRefreshed,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case 'champ.update':
|
||||||
|
await shardState.upsertChamp(event)
|
||||||
|
return
|
||||||
|
case 'champ.remove':
|
||||||
|
await shardState.removeChamp(event.serial)
|
||||||
|
return
|
||||||
|
case 'page.new':
|
||||||
|
case 'page.updated':
|
||||||
|
await shardState.upsertPage(event)
|
||||||
|
return
|
||||||
|
case 'page.closed':
|
||||||
|
await shardState.removePage(event.pageId)
|
||||||
|
return
|
||||||
|
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
||||||
|
case 'guild.update':
|
||||||
|
await shardState.upsertGuild(event)
|
||||||
|
return
|
||||||
|
case 'guild.remove':
|
||||||
|
await shardState.removeGuild(event.id)
|
||||||
|
return
|
||||||
|
case 'city.update':
|
||||||
|
// Upserts the board AND captures term history (idempotent).
|
||||||
|
await shardState.upsertGovernor(event)
|
||||||
|
return
|
||||||
|
case 'presence.online':
|
||||||
|
await shardState.setPresence(event)
|
||||||
|
return
|
||||||
|
case 'house.update':
|
||||||
|
await shardState.upsertHouseRegistry(event)
|
||||||
|
return
|
||||||
|
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':
|
||||||
|
// A shard whose operator never edited Server.cfg publishes ServUO's stock
|
||||||
|
// "My Shard". That is the shard saying *unnamed*, not a name, so the site
|
||||||
|
// answers with its own — the rules page reading "My Shard" under a header
|
||||||
|
// reading UOMysticmoon is the shard failing to introduce itself.
|
||||||
|
//
|
||||||
|
// Normalized HERE rather than on read because the ruleset is also live: the
|
||||||
|
// same `event` object is handed to the SSE broadcast a few lines below, and
|
||||||
|
// a read-time fix would be undone by the next reconnect's frame.
|
||||||
|
event.shard = await resolveShardName(event.shard, deps)
|
||||||
|
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
|
||||||
|
// Player-vendor market index. Each frame is authoritative for one shop, so
|
||||||
|
// the model replaces that vendor's whole listing set rather than merging.
|
||||||
|
//
|
||||||
|
// NOT in LOGGED_KINDS, and this is the strongest case of the three v3 kinds:
|
||||||
|
// one frame carries up to 250 listings, the sweep re-emits a shop on any
|
||||||
|
// price change, and appending each of those to the event log would make
|
||||||
|
// shard_events mostly a price history nobody reads. The market IS the state.
|
||||||
|
case 'vendor.listing':
|
||||||
|
await deps.shardMarket.upsertVendor(event)
|
||||||
|
return
|
||||||
|
case 'vendor.listing.remove':
|
||||||
|
await deps.shardMarket.removeVendor(event.serial)
|
||||||
|
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.
|
||||||
|
if (event.account) await deps.shardLinks.removeByAccount(event.account)
|
||||||
|
return
|
||||||
|
// guild.join / account.audit → logged; region.enter → broadcast-only.
|
||||||
|
default:
|
||||||
|
// No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and
|
||||||
|
// broadcasting still happen in ingest().
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingest one event. Returns { logged, stored } for tests/stats. `fromBackfill`
|
||||||
|
// suppresses the SSE broadcast (a reconnect replay shouldn't re-animate the
|
||||||
|
// live ticker). Never throws — a bad single event must not kill the feed.
|
||||||
|
// Resolve the injectable dependencies to their live defaults (tests override a
|
||||||
|
// subset). Split out so ingest() isn't penalised for the fan of `|| default`s.
|
||||||
|
function resolveDeps(deps) {
|
||||||
|
return {
|
||||||
|
shardEvents: deps.shardEvents || shardEventsModel,
|
||||||
|
shardState: deps.shardState || shardStateModel,
|
||||||
|
shardLinks: deps.shardLinks || shardLinksModel,
|
||||||
|
shardMarket: deps.shardMarket || shardMarketModel,
|
||||||
|
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||||
|
settings: deps.settings || settingsModel,
|
||||||
|
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||||
|
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||||
|
log: deps.log || defaultLog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingest(event, deps = {}) {
|
||||||
|
const d = resolveDeps(deps)
|
||||||
|
|
||||||
|
if (!event || typeof event.kind !== 'string') return { logged: false, stored: false }
|
||||||
|
// ws.hello / pong are transport frames, not game events.
|
||||||
|
if (event.kind === 'ws.hello' || event.kind === 'pong') return { logged: false, stored: false }
|
||||||
|
|
||||||
|
const t = Number.isFinite(event.t) ? event.t : Date.now()
|
||||||
|
let stored = false
|
||||||
|
let logged = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyStateChange(event, d)
|
||||||
|
} catch (err) {
|
||||||
|
d.log.warn('state-change write failed', { kind: event.kind, message: err.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldLog(event)) {
|
||||||
|
logged = true
|
||||||
|
try {
|
||||||
|
stored = await d.shardEvents.append({ kind: event.kind, t, bootId: state.bootId, payload: event })
|
||||||
|
} catch (err) {
|
||||||
|
d.log.warn('event log write failed', { kind: event.kind, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deps.fromBackfill) {
|
||||||
|
// Broadcast is async since v3 (it reads the visibility config to decide what
|
||||||
|
// each subscriber may see). Fire-and-forget, like the push fan-out below: a
|
||||||
|
// slow config read must never delay or fail ingest.
|
||||||
|
Promise.resolve(d.broadcast(event)).catch((err) =>
|
||||||
|
d.log.warn('broadcast failed', { kind: event.kind, message: err.message }),
|
||||||
|
)
|
||||||
|
// Opt-in push fan-out, off the same event source as the SSE broadcast.
|
||||||
|
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
|
||||||
|
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
|
||||||
|
Promise.resolve(d.pushDispatch(event, { shardLinks: d.shardLinks })).catch((err) =>
|
||||||
|
d.log.warn('push dispatch failed', { kind: event.kind, message: err.message }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logged, stored }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ingest, shouldLog, reset, LOGGED_KINDS, state }
|
||||||
49
server/utils/shardPush.js
Normal file
49
server/utils/shardPush.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// ── Shard event → push fan-out ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// MODULE-UO CONTENT, still living in core — the inverted half of
|
||||||
|
// MODULE_SYSTEM.md §1.8's second entangled file. `utils/pushDispatch.js` is core
|
||||||
|
// infrastructure, but its `fromShardEvent()` required the shardLinks model and
|
||||||
|
// the shard event mapper, which is a core file importing content. PR 4 inverted
|
||||||
|
// it: `publish()` stays core, and this — the thing that knows what a shard event
|
||||||
|
// is — moved out to call it. Phase 3 moves this file to module-uo whole, where it
|
||||||
|
// will reach `publish` through `ctx.push.publish` instead of a require.
|
||||||
|
//
|
||||||
|
// Owner resolution is the reason this cannot just be a mapper: a personal
|
||||||
|
// (owner-keyed) target names a GAME account, and turning that into a website user
|
||||||
|
// needs the shardLinks model. An unlinked account is simply nobody to notify.
|
||||||
|
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const { mapShardEvent } = require('../config/shardStreams')
|
||||||
|
const { push } = require('../core')
|
||||||
|
|
||||||
|
const { publish } = push
|
||||||
|
const log = require('../core').logger('shard-push')
|
||||||
|
|
||||||
|
// Fan a shard event out to push. Resolves personal (owner-keyed) targets to the
|
||||||
|
// owning website user via shardLinks (an unlinked account → nobody to notify).
|
||||||
|
// Never throws — a dead relay must never affect ingest.
|
||||||
|
async function fromShardEvent(event, deps = {}) {
|
||||||
|
const links = deps.shardLinks || shardLinks
|
||||||
|
const doPublish = deps.publish || publish
|
||||||
|
const targets = mapShardEvent(event, deps.tracker)
|
||||||
|
for (const t of targets) {
|
||||||
|
try {
|
||||||
|
if (t.ownerAccount) {
|
||||||
|
let owner = null
|
||||||
|
try {
|
||||||
|
owner = await links.getByAccount(t.ownerAccount)
|
||||||
|
} catch {
|
||||||
|
owner = null
|
||||||
|
}
|
||||||
|
if (!owner || owner.userId == null) continue
|
||||||
|
await doPublish(t.streamId, { ref: t.ref, ownerUserId: owner.userId }, deps)
|
||||||
|
} else {
|
||||||
|
await doPublish(t.streamId, { ref: t.ref }, deps)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('push dispatch target failed', { streamId: t.streamId, message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fromShardEvent }
|
||||||
26
server/utils/shardSales.js
Normal file
26
server/utils/shardSales.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Recent player-vendor sales for a set of game accounts. Shared by the player
|
||||||
|
// self endpoint (the caller's linked accounts) and the admin user-detail
|
||||||
|
// endpoint (a target user's linked accounts). Reads the site's own ingested
|
||||||
|
// event log — no sidecar round-trip — and filters to sales whose owning account
|
||||||
|
// is in the set. Newest 50, already newest-first from shardEvents.list.
|
||||||
|
|
||||||
|
const shardEvents = require('../model/shardEvents/shardEvents.model')
|
||||||
|
|
||||||
|
async function salesForAccounts(accounts) {
|
||||||
|
const set = accounts instanceof Set ? accounts : new Set(accounts)
|
||||||
|
if (set.size === 0) return []
|
||||||
|
const events = await shardEvents.list({ kind: 'vendor.sale', limit: 500 })
|
||||||
|
return events
|
||||||
|
.filter((e) => e.payload && set.has(e.payload.ownerAcct))
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((e) => ({
|
||||||
|
t: e.t,
|
||||||
|
itemType: e.payload.itemType,
|
||||||
|
amount: e.payload.amount,
|
||||||
|
price: e.payload.price,
|
||||||
|
commission: e.payload.commission,
|
||||||
|
ownerAcct: e.payload.ownerAcct,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { salesForAccounts }
|
||||||
435
server/utils/shardVisibility.js
Normal file
435
server/utils/shardVisibility.js
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
// ── Shard feature visibility ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Admin-configurable, per-feature and per-field audience control over every
|
||||||
|
// shard-derived surface on the site. Replaces the hardcoded split that used to
|
||||||
|
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
|
||||||
|
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
|
||||||
|
//
|
||||||
|
// Design rules (docs/link/v3.md §3):
|
||||||
|
//
|
||||||
|
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
|
||||||
|
// is a dumb forwarder: it accepts frames, stores them, forwards them
|
||||||
|
// verbatim, and serves store-backed reads. It defines no audiences.
|
||||||
|
// • Every default reproduces the behavior that shipped before this module, so
|
||||||
|
// installing it changes nothing until an admin edits the config.
|
||||||
|
// • Two rules an admin CANNOT override:
|
||||||
|
// 1. `acct` / `webId` are admin-only, always. They are not in-game
|
||||||
|
// visible (unlike a character name) and are not configurable fields.
|
||||||
|
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
|
||||||
|
// Fail closed — this is what keeps the kind map a security boundary
|
||||||
|
// rather than a convenience filter.
|
||||||
|
//
|
||||||
|
// The audience ladder is ordered; each rung implies the ones below it.
|
||||||
|
|
||||||
|
const db = require('../model/shardVisibility/shardVisibility.model')
|
||||||
|
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const { auth } = require('../core')
|
||||||
|
const log = require('../core').logger('shard-visibility')
|
||||||
|
|
||||||
|
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||||
|
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||||
|
|
||||||
|
const isLevel = (level) => RANK.has(level)
|
||||||
|
|
||||||
|
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||||
|
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||||
|
// do that — whichever direction it picks, it fails open on one side. So:
|
||||||
|
//
|
||||||
|
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||||
|
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||||
|
//
|
||||||
|
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||||
|
// typo, a future rung this build doesn't know, a value from a caller that
|
||||||
|
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||||
|
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||||
|
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||||
|
|
||||||
|
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||||
|
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||||
|
|
||||||
|
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||||
|
const rank = viewerRank
|
||||||
|
|
||||||
|
// ── Features ───────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||||
|
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||||
|
// not listed here is visible whenever the feature itself is.
|
||||||
|
//
|
||||||
|
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||||
|
|
||||||
|
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||||
|
|
||||||
|
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
||||||
|
// frames nest actors (`leader.acct`), but several read models flatten them
|
||||||
|
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
||||||
|
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
||||||
|
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
||||||
|
// anonymous callers while the same account name was correctly stripped from the
|
||||||
|
// live `house.decay` frame.
|
||||||
|
//
|
||||||
|
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
||||||
|
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
||||||
|
// fail closed for shapes nobody has written yet.
|
||||||
|
const LOCKED_SUFFIXES = ['acct', 'webid']
|
||||||
|
const isLockedField = (key) => {
|
||||||
|
const k = String(key).toLowerCase()
|
||||||
|
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES = {
|
||||||
|
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||||
|
status: { audience: 'anonymous', fields: {} },
|
||||||
|
activity: { audience: 'anonymous', fields: {} },
|
||||||
|
champs: { audience: 'anonymous', fields: {} },
|
||||||
|
guilds: { audience: 'anonymous', fields: {} },
|
||||||
|
governors: { audience: 'anonymous', fields: {} },
|
||||||
|
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||||
|
// `owner` is the actor object on the house.decay/house.update frames;
|
||||||
|
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||||
|
// REST read models. Both are listed so one rule covers the wire and the read
|
||||||
|
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||||
|
houses: {
|
||||||
|
audience: 'anonymous',
|
||||||
|
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||||
|
},
|
||||||
|
// /public/shard/online listed linked staff to everyone but gated location to
|
||||||
|
// admin+moderator — which is exactly the `staff` rung.
|
||||||
|
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||||
|
|
||||||
|
// ── New in v3. ──
|
||||||
|
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||||
|
atlas: { audience: 'anonymous', fields: {} },
|
||||||
|
// `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.
|
||||||
|
//
|
||||||
|
// `ownerName` and `location` were pre-wired here by Part A, before the frame
|
||||||
|
// existed; both were re-checked against the real `vendor.listing` and both are
|
||||||
|
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
|
||||||
|
// `location` is a NESTED object on the wire and on the read model precisely so
|
||||||
|
// that one rule hides map, coordinates, region and house together — five flat
|
||||||
|
// keys would be five rules that drift apart.
|
||||||
|
//
|
||||||
|
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||||
|
// lists both: an admin who hides the owner's name and is left with a serial
|
||||||
|
// that every other board resolves back to that name has not hidden anything.
|
||||||
|
market: {
|
||||||
|
audience: 'anonymous',
|
||||||
|
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||||
|
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||||
|
|
||||||
|
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every event kind that may ever leave the admin channel must appear here.
|
||||||
|
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||||
|
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||||
|
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||||
|
|
||||||
|
const KIND_FEATURE = new Map(
|
||||||
|
Object.entries({
|
||||||
|
// status / lifecycle
|
||||||
|
'server.hello': 'status',
|
||||||
|
'server.shutdown': 'status',
|
||||||
|
'server.crashed': 'status',
|
||||||
|
'economy.supply': 'status',
|
||||||
|
// activity feed
|
||||||
|
'player.death': 'activity',
|
||||||
|
'player.murdered': 'activity',
|
||||||
|
'mob.killed': 'activity',
|
||||||
|
'quest.complete': 'activity',
|
||||||
|
'skill.gain': 'activity',
|
||||||
|
'fame.change': 'activity',
|
||||||
|
'karma.change': 'activity',
|
||||||
|
'mob.login': 'activity',
|
||||||
|
'mob.logout': 'activity',
|
||||||
|
// boards
|
||||||
|
'champ.update': 'champs',
|
||||||
|
'champ.remove': 'champs',
|
||||||
|
'guild.update': 'guilds',
|
||||||
|
'guild.remove': 'guilds',
|
||||||
|
'guild.join': 'guilds',
|
||||||
|
'city.update': 'governors',
|
||||||
|
'presence.online': 'presence',
|
||||||
|
'region.enter': 'presence',
|
||||||
|
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||||
|
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||||
|
// off the map deliberately, so it remains admin-only exactly as before.
|
||||||
|
'house.decay': 'houses',
|
||||||
|
// v3
|
||||||
|
'world.ruleset': 'ruleset',
|
||||||
|
'points.board': 'leaderboards',
|
||||||
|
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||||
|
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||||
|
// inventories would be the site's biggest bandwidth consumer and no page
|
||||||
|
// needs it live. An admin can turn it on.
|
||||||
|
'vendor.listing': 'market',
|
||||||
|
'vendor.listing.remove': 'market',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||||
|
// are unaffected; only the live stream is suppressed.
|
||||||
|
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||||
|
|
||||||
|
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||||
|
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||||
|
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||||
|
// no longer drift.
|
||||||
|
const PUBLIC_KINDS = new Set(
|
||||||
|
[...KIND_FEATURE.entries()]
|
||||||
|
.filter(([, feature]) => {
|
||||||
|
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||||
|
return FEATURES[feature].audience === 'anonymous'
|
||||||
|
})
|
||||||
|
.map(([kind]) => kind),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONFIG_TTL_MS = 5000
|
||||||
|
let cache = null
|
||||||
|
let cachedAt = 0
|
||||||
|
|
||||||
|
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||||
|
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||||
|
// invalid rung falls back to the default rather than failing open.
|
||||||
|
function applyRow(name, row) {
|
||||||
|
const base = FEATURES[name]
|
||||||
|
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||||
|
const fields = { ...base.fields }
|
||||||
|
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||||
|
if (isLockedField(field)) continue // rule 1: not configurable
|
||||||
|
if (isLevel(level)) fields[field] = level
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: row ? !!row.enabled : true,
|
||||||
|
audience,
|
||||||
|
fields,
|
||||||
|
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileDefaults() {
|
||||||
|
const out = {}
|
||||||
|
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||||
|
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||||
|
// "what the site did before" rather than to "everything is public".
|
||||||
|
async function getConfig() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||||
|
try {
|
||||||
|
const rows = await db.listAll()
|
||||||
|
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||||
|
const out = {}
|
||||||
|
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||||
|
cache = out
|
||||||
|
cachedAt = now
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getConfig; falling back to defaults', err)
|
||||||
|
cache = cache || compileDefaults()
|
||||||
|
cachedAt = now
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
cache = null
|
||||||
|
cachedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// anonymous no session
|
||||||
|
// logged_in authenticated, no linked game account
|
||||||
|
// player authenticated with a linked game account
|
||||||
|
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||||
|
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||||
|
// resolves by link status like any other member; mapping it to staff
|
||||||
|
// here would silently widen what editors can see.
|
||||||
|
// admin admin
|
||||||
|
//
|
||||||
|
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||||
|
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||||
|
// self-service.
|
||||||
|
|
||||||
|
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||||
|
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||||
|
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||||
|
// costs a query on the logged-in-member path.
|
||||||
|
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||||
|
const linkCache = new Map() // userId → { hasLink, at }
|
||||||
|
|
||||||
|
async function hasLinkedAccount(userId) {
|
||||||
|
const hit = linkCache.get(userId)
|
||||||
|
const now = Date.now()
|
||||||
|
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||||
|
let hasLink = false
|
||||||
|
try {
|
||||||
|
const links = await shardLinks.listForUser(userId)
|
||||||
|
hasLink = Array.isArray(links) && links.length > 0
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||||
|
}
|
||||||
|
linkCache.set(userId, { hasLink, at: now })
|
||||||
|
return hasLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop a user's cached link status (called when a link is created or removed so
|
||||||
|
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||||
|
const forgetUser = (userId) => linkCache.delete(userId)
|
||||||
|
|
||||||
|
async function viewerLevel(req) {
|
||||||
|
const viewer = req.user || auth.getUserFromRequest(req)
|
||||||
|
if (!viewer) return 'anonymous'
|
||||||
|
if (viewer.role === 'admin') return 'admin'
|
||||||
|
if (viewer.role === 'moderator') return 'staff'
|
||||||
|
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||||
|
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||||
|
// resolved level on the request so controllers can project without re-resolving.
|
||||||
|
function requireFeature(name) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const config = await getConfig()
|
||||||
|
const feature = config[name]
|
||||||
|
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||||
|
const level = await viewerLevel(req)
|
||||||
|
req.viewerLevel = level
|
||||||
|
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||||
|
return next()
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`requireFeature(${name})`, err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||||
|
// first (so acct/webId can never survive below admin), then the feature's
|
||||||
|
// configured field rules. Recurses into arrays and nested objects because the
|
||||||
|
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||||
|
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
||||||
|
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
||||||
|
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
||||||
|
// backed read models — whose rows carry real Date columns — differ from the
|
||||||
|
// pure-JSON wire frames the projection was first written against.
|
||||||
|
const isPlainObject = (v) => {
|
||||||
|
if (v === null || typeof v !== 'object') return false
|
||||||
|
const proto = Object.getPrototypeOf(v)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectValue(value, rules, level) {
|
||||||
|
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||||
|
if (!isPlainObject(value)) return value
|
||||||
|
const out = {}
|
||||||
|
for (const [key, v] of Object.entries(value)) {
|
||||||
|
// Locked fields are checked by meaning first, so no configured rule (and no
|
||||||
|
// flattened spelling) can widen them past `admin`.
|
||||||
|
const required = isLockedField(key) ? 'admin' : rules[key]
|
||||||
|
if (required && !meets(level, required)) continue
|
||||||
|
out[key] = projectValue(v, rules, level)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||||
|
// when explicitly passed; callers should always pass a resolved level.
|
||||||
|
function projectFeature(name, payload, level, config) {
|
||||||
|
const feature = config?.[name]
|
||||||
|
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||||
|
return projectValue(payload, rules, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience for controllers: resolve config once, project, return.
|
||||||
|
async function project(name, payload, req) {
|
||||||
|
const config = await getConfig()
|
||||||
|
const level = req.viewerLevel || (await viewerLevel(req))
|
||||||
|
return projectFeature(name, payload, level, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||||
|
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||||
|
function kindVisibleTo(kind, level, config) {
|
||||||
|
if (level === 'admin') return true
|
||||||
|
const name = KIND_FEATURE.get(kind)
|
||||||
|
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||||
|
const feature = config?.[name]
|
||||||
|
if (!feature || !feature.enabled || !feature.stream) return false
|
||||||
|
return meets(level, feature.audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event kinds a viewer at `level` may read under the CURRENT config. This is
|
||||||
|
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
||||||
|
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
||||||
|
// this kind, given what the admin has configured?".
|
||||||
|
//
|
||||||
|
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
||||||
|
// feature whose live firehose is off (market) is still readable from the stored
|
||||||
|
// history. Unmapped kinds are absent by construction (rule 2).
|
||||||
|
function visibleKinds(level, config) {
|
||||||
|
return [...KIND_FEATURE.entries()]
|
||||||
|
.filter(([, name]) => {
|
||||||
|
const feature = config?.[name]
|
||||||
|
return !!feature && feature.enabled && meets(level, feature.audience)
|
||||||
|
})
|
||||||
|
.map(([kind]) => kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||||
|
// renders a link that would 403.
|
||||||
|
function visibleFeatures(level, config) {
|
||||||
|
return FEATURE_NAMES.filter((name) => {
|
||||||
|
const feature = config[name]
|
||||||
|
return feature.enabled && meets(level, feature.audience)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LADDER,
|
||||||
|
FEATURES,
|
||||||
|
FEATURE_NAMES,
|
||||||
|
LOCKED_FIELDS,
|
||||||
|
KIND_FEATURE,
|
||||||
|
PUBLIC_KINDS,
|
||||||
|
DEFAULT_STREAM_OFF,
|
||||||
|
isLevel,
|
||||||
|
isFeature,
|
||||||
|
isLockedField,
|
||||||
|
rank,
|
||||||
|
meets,
|
||||||
|
getConfig,
|
||||||
|
invalidate,
|
||||||
|
compileDefaults,
|
||||||
|
viewerLevel,
|
||||||
|
forgetUser,
|
||||||
|
requireFeature,
|
||||||
|
projectFeature,
|
||||||
|
project,
|
||||||
|
kindVisibleTo,
|
||||||
|
visibleKinds,
|
||||||
|
visibleFeatures,
|
||||||
|
}
|
||||||
686
server/utils/spawnAtlasParse.js
Normal file
686
server/utils/spawnAtlasParse.js
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
|
||||||
|
//
|
||||||
|
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
|
||||||
|
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
|
||||||
|
// what lets the test suite cover them in CI, where no ServUO tree exists: the
|
||||||
|
// tests hand these functions literal XML strings.
|
||||||
|
//
|
||||||
|
// Four source shapes, two very different parsing strategies:
|
||||||
|
//
|
||||||
|
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
|
||||||
|
// → streaming regex, never a DOM. See parsePoints().
|
||||||
|
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
|
||||||
|
// Data/Locations/*.xml nested <parent>/<child>
|
||||||
|
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
|
||||||
|
// → the small recursive tokenizer below.
|
||||||
|
//
|
||||||
|
// The server has zero XML dependencies and this adds none. The tokenizer is
|
||||||
|
// deliberately a *subset* parser: it handles the constructs these four files
|
||||||
|
// actually use (elements, attributes, self-closing tags, comments, the XML
|
||||||
|
// declaration, CDATA, the five predefined entities plus numeric refs) and
|
||||||
|
// nothing else. It is not a general-purpose XML parser and must not be reused
|
||||||
|
// as one — no namespaces, no DTDs, no entity declarations.
|
||||||
|
|
||||||
|
// ── Entities ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NAMED_ENTITIES = {
|
||||||
|
amp: '&',
|
||||||
|
lt: '<',
|
||||||
|
gt: '>',
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
|
||||||
|
// Level 3"), so entity decoding is load-bearing here, not decorative.
|
||||||
|
function decodeEntities(text) {
|
||||||
|
if (!text.includes('&')) return text
|
||||||
|
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
|
||||||
|
if (body[0] === '#') {
|
||||||
|
const code =
|
||||||
|
body[1] === 'x' || body[1] === 'X'
|
||||||
|
? Number.parseInt(body.slice(2), 16)
|
||||||
|
: Number.parseInt(body.slice(1), 10)
|
||||||
|
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
||||||
|
}
|
||||||
|
const named = NAMED_ENTITIES[body.toLowerCase()]
|
||||||
|
return named === undefined ? match : named
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The tokenizer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
|
||||||
|
|
||||||
|
function parseAttrs(source) {
|
||||||
|
const attrs = {}
|
||||||
|
ATTR_RE.lastIndex = 0
|
||||||
|
let match
|
||||||
|
while ((match = ATTR_RE.exec(source)) !== null) {
|
||||||
|
const raw = match[3] !== undefined ? match[3] : match[4]
|
||||||
|
attrs[match[1]] = decodeEntities(raw)
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a small nested XML document into `{ name, attrs, children, text }`.
|
||||||
|
*
|
||||||
|
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
|
||||||
|
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
|
||||||
|
* document with no elements.
|
||||||
|
*
|
||||||
|
* Mismatched or stray closing tags are ignored rather than thrown on: these are
|
||||||
|
* hand-maintained shard config files, and one malformed region should degrade
|
||||||
|
* to a missing region, not abort a build that is otherwise fine.
|
||||||
|
*/
|
||||||
|
function parseXml(source) {
|
||||||
|
const text = String(source)
|
||||||
|
const root = { name: '#document', attrs: {}, children: [], text: '' }
|
||||||
|
const stack = [root]
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const lt = text.indexOf('<', i)
|
||||||
|
if (lt === -1) {
|
||||||
|
appendText(stack[stack.length - 1], text.slice(i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
|
||||||
|
|
||||||
|
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
|
||||||
|
if (text.startsWith('<!--', lt)) {
|
||||||
|
const end = text.indexOf('-->', lt + 4)
|
||||||
|
i = end === -1 ? text.length : end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith('<![CDATA[', lt)) {
|
||||||
|
const end = text.indexOf(']]>', lt + 9)
|
||||||
|
const stop = end === -1 ? text.length : end
|
||||||
|
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
|
||||||
|
i = end === -1 ? text.length : end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith('<?', lt)) {
|
||||||
|
const end = text.indexOf('?>', lt + 2)
|
||||||
|
i = end === -1 ? text.length : end + 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith('<!', lt)) {
|
||||||
|
const end = text.indexOf('>', lt + 2)
|
||||||
|
i = end === -1 ? text.length : end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const gt = findTagEnd(text, lt)
|
||||||
|
if (gt === -1) {
|
||||||
|
// Unterminated tag: nothing sane is left to read.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const inner = text.slice(lt + 1, gt)
|
||||||
|
|
||||||
|
if (inner[0] === '/') {
|
||||||
|
const name = inner.slice(1).trim()
|
||||||
|
// Pop to the nearest matching open element. If there is no match the tag
|
||||||
|
// is stray and we drop it rather than unwinding the whole stack.
|
||||||
|
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
|
||||||
|
if (stack[depth].name === name) {
|
||||||
|
stack.length = depth
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = gt + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfClosing = inner.endsWith('/')
|
||||||
|
const body = selfClosing ? inner.slice(0, -1) : inner
|
||||||
|
const space = body.search(/\s/)
|
||||||
|
const name = (space === -1 ? body : body.slice(0, space)).trim()
|
||||||
|
const node = {
|
||||||
|
name,
|
||||||
|
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
|
||||||
|
children: [],
|
||||||
|
text: '',
|
||||||
|
}
|
||||||
|
stack[stack.length - 1].children.push(node)
|
||||||
|
if (!selfClosing) stack.push(node)
|
||||||
|
i = gt + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.children.length > 0 ? root.children[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// `>` inside a quoted attribute value must not end the tag.
|
||||||
|
function findTagEnd(text, from) {
|
||||||
|
let quote = null
|
||||||
|
for (let i = from + 1; i < text.length; i += 1) {
|
||||||
|
const ch = text[i]
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch
|
||||||
|
} else if (ch === '>') {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendText(node, chunk) {
|
||||||
|
if (chunk.trim() === '') return
|
||||||
|
appendRawText(node, decodeEntities(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRawText(node, chunk) {
|
||||||
|
node.text = node.text ? `${node.text}${chunk}` : chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
function childrenNamed(node, name) {
|
||||||
|
if (!node || !node.children) return []
|
||||||
|
return node.children.filter((child) => child.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Facet names ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
|
||||||
|
// or rename them when its maps are updated, so nothing here may name Felucca,
|
||||||
|
// Trammel or any other stock facet. The facet set is whatever the shard's own
|
||||||
|
// files say it is, discovered at parse time.
|
||||||
|
//
|
||||||
|
// The complication is that the sources disagree about spelling for the SAME
|
||||||
|
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
|
||||||
|
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
|
||||||
|
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
|
||||||
|
// silently — the landmark bucket is keyed differently from the points looking it
|
||||||
|
// up, so the fallback never fires and every unregioned spawn on those facets
|
||||||
|
// reads "Wilderness".
|
||||||
|
//
|
||||||
|
// Reconciliation is therefore done by MATCHING, not by a lookup table:
|
||||||
|
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
|
||||||
|
// a loosely-spelled name against the canonical set discovered from the shard's
|
||||||
|
// own data. A facet nobody else mentions keeps its own name rather than being
|
||||||
|
// dropped.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
|
||||||
|
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
|
||||||
|
*/
|
||||||
|
function facetKey(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a key → canonical-spelling lookup from the authoritative facet names.
|
||||||
|
*
|
||||||
|
* The authority is what the spawn records and region definitions actually say,
|
||||||
|
* since those are the names the atlas keys everything on. Later names do not
|
||||||
|
* overwrite earlier ones, so the first source wins consistently.
|
||||||
|
*/
|
||||||
|
function buildFacetIndex(names) {
|
||||||
|
const index = new Map()
|
||||||
|
for (const name of names) {
|
||||||
|
const key = facetKey(name)
|
||||||
|
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a loosely-spelled facet name against the discovered canonical set.
|
||||||
|
*
|
||||||
|
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
|
||||||
|
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
|
||||||
|
* more specific facet wins over a shorter one that merely prefixes it.
|
||||||
|
*
|
||||||
|
* A name matching nothing is returned trimmed rather than dropped — on a shard
|
||||||
|
* with a custom facet that is a real facet the atlas simply has no spawns for
|
||||||
|
* yet, and inventing a match would be worse than leaving it alone.
|
||||||
|
*/
|
||||||
|
function resolveFacetName(value, index) {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
const key = facetKey(raw)
|
||||||
|
if (key === '') return ''
|
||||||
|
if (index.has(key)) return index.get(key)
|
||||||
|
|
||||||
|
let best = null
|
||||||
|
for (const [candidateKey, canonical] of index) {
|
||||||
|
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
|
||||||
|
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
|
||||||
|
}
|
||||||
|
return best ?? raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small coercions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toInt(value, fallback = 0) {
|
||||||
|
const n = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value) {
|
||||||
|
return String(value).trim().toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
|
||||||
|
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
|
||||||
|
* mainly lowercases and collapses punctuation.
|
||||||
|
*/
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Objects2 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `<Objects2>` value into `[{ type, max }]`.
|
||||||
|
*
|
||||||
|
* The format is one or more segments joined by `:OBJ=`, each segment being
|
||||||
|
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
|
||||||
|
* every following token is a `KEY=value` pair. Verified against trammel.xml,
|
||||||
|
* where a single point carries six types:
|
||||||
|
*
|
||||||
|
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
|
||||||
|
*
|
||||||
|
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
|
||||||
|
* first. `MX` is that type's own max count and is what the atlas displays;
|
||||||
|
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
|
||||||
|
*
|
||||||
|
* The type token itself may carry XmlSpawner directives appended to the class
|
||||||
|
* name — property assignments after `/` and an amount/argument list after `,`:
|
||||||
|
*
|
||||||
|
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
|
||||||
|
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
|
||||||
|
*
|
||||||
|
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
|
||||||
|
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
|
||||||
|
* 71 of 845 entries were affected before this was stripped. Only the leading
|
||||||
|
* class name identifies the creature, so everything from the first `/` or `,`
|
||||||
|
* is dropped.
|
||||||
|
*/
|
||||||
|
/** Reduce an XmlSpawner type token to the bare class name. */
|
||||||
|
function stripSpawnerDirectives(token) {
|
||||||
|
const cut = String(token).search(/[/,]/)
|
||||||
|
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjects2(value) {
|
||||||
|
const source = String(value ?? '').trim()
|
||||||
|
if (source === '') return []
|
||||||
|
|
||||||
|
return source
|
||||||
|
.split(':OBJ=')
|
||||||
|
.map((segment) => {
|
||||||
|
const tokens = segment.split(':')
|
||||||
|
const type = stripSpawnerDirectives(tokens.shift() ?? '')
|
||||||
|
if (type === '') return null
|
||||||
|
let max = 1
|
||||||
|
for (const token of tokens) {
|
||||||
|
const eq = token.indexOf('=')
|
||||||
|
if (eq === -1) continue
|
||||||
|
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
|
||||||
|
max = toInt(token.slice(eq + 1), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type, max }
|
||||||
|
})
|
||||||
|
.filter((entry) => entry !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
|
||||||
|
|
||||||
|
function tagValue(block, name) {
|
||||||
|
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
|
||||||
|
return match ? decodeEntities(match[1]).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `Spawns/<facet>.xml` file into spawn point records.
|
||||||
|
*
|
||||||
|
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
|
||||||
|
* and putting them through a DOM builder would allocate a node per element for
|
||||||
|
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||||
|
* flat, so a per-record regex sweep is both correct and cheap.
|
||||||
|
*
|
||||||
|
* Only the fields the site can actually show are kept. Everything to do with
|
||||||
|
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
||||||
|
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
||||||
|
* committed artifact under 1 MB.
|
||||||
|
*
|
||||||
|
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||||
|
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||||
|
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A spawner's respawn window, in seconds.
|
||||||
|
*
|
||||||
|
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
|
||||||
|
* it is false, which is minutes — the same default XmlSpawner assumes.
|
||||||
|
*/
|
||||||
|
function delaySeconds(block) {
|
||||||
|
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
|
||||||
|
return {
|
||||||
|
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
|
||||||
|
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePoints(source) {
|
||||||
|
const text = String(source)
|
||||||
|
const points = []
|
||||||
|
POINT_RE.lastIndex = 0
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = POINT_RE.exec(text)) !== null) {
|
||||||
|
const block = match[1]
|
||||||
|
// Reported exactly as written. `<Map>` is the authority the rest of the
|
||||||
|
// atlas keys on, so it is never rewritten.
|
||||||
|
const facet = tagValue(block, 'Map')
|
||||||
|
if (facet === '') continue
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
name: tagValue(block, 'Name'),
|
||||||
|
facet,
|
||||||
|
x: toInt(tagValue(block, 'X')),
|
||||||
|
y: toInt(tagValue(block, 'Y')),
|
||||||
|
width: toInt(tagValue(block, 'Width')),
|
||||||
|
height: toInt(tagValue(block, 'Height')),
|
||||||
|
range: toInt(tagValue(block, 'Range')),
|
||||||
|
maxCount: toInt(tagValue(block, 'MaxCount')),
|
||||||
|
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
|
||||||
|
// writes minutes by default and switches to seconds only when a spawner's
|
||||||
|
// delay does not divide into whole minutes, flagging that with
|
||||||
|
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
|
||||||
|
// literally the two are indistinguishable — a `5` means five minutes on
|
||||||
|
// one spawner and five seconds on the next — so a consumer that assumed
|
||||||
|
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
|
||||||
|
// second-flagged spawners, few enough to look like noise and quietly
|
||||||
|
// mislabel.
|
||||||
|
...delaySeconds(block),
|
||||||
|
// Time-of-day gating: TODMode 0 means "always", in which case the start
|
||||||
|
// and end values are meaningless and the site must not render them.
|
||||||
|
todStart: toInt(tagValue(block, 'TODStart')),
|
||||||
|
todEnd: toInt(tagValue(block, 'TODEnd')),
|
||||||
|
todMode: toInt(tagValue(block, 'TODMode')),
|
||||||
|
// A spawner switched off in-world spawns nothing; the build filters these
|
||||||
|
// out so the atlas describes what actually appears, not what is merely
|
||||||
|
// configured. Parsed here so the decision stays in the build script.
|
||||||
|
running: toBool(tagValue(block, 'IsRunning')),
|
||||||
|
types: parseObjects2(tagValue(block, 'Objects2')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data/Regions.xml ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
|
||||||
|
*
|
||||||
|
* Regions nest: a `<region>` may contain further `<region>` elements, and the
|
||||||
|
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
|
||||||
|
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
|
||||||
|
* spawn point — but their children are still walked, and a child that omits
|
||||||
|
* `priority` inherits its parent's rather than defaulting to 0, which would
|
||||||
|
* quietly sort it below every top-level region.
|
||||||
|
*/
|
||||||
|
function parseRegions(source) {
|
||||||
|
const root = parseXml(source)
|
||||||
|
const regions = []
|
||||||
|
if (!root) return regions
|
||||||
|
|
||||||
|
for (const facetNode of childrenNamed(root, 'Facet')) {
|
||||||
|
const facet = (facetNode.attrs.name || '').trim()
|
||||||
|
if (facet === '') continue
|
||||||
|
walkRegions(facetNode, facet, null, 0, regions)
|
||||||
|
}
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkRegions(node, facet, parentName, parentPriority, out) {
|
||||||
|
for (const regionNode of childrenNamed(node, 'region')) {
|
||||||
|
const name = regionNode.attrs.name || ''
|
||||||
|
const priority = Object.hasOwn(regionNode.attrs, 'priority')
|
||||||
|
? toInt(regionNode.attrs.priority, parentPriority)
|
||||||
|
: parentPriority
|
||||||
|
|
||||||
|
if (name !== '') {
|
||||||
|
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
|
||||||
|
x: toInt(rect.attrs.x),
|
||||||
|
y: toInt(rect.attrs.y),
|
||||||
|
width: toInt(rect.attrs.width),
|
||||||
|
height: toInt(rect.attrs.height),
|
||||||
|
}))
|
||||||
|
// A named region with no rects (some exist purely to carry music or a
|
||||||
|
// `go` point) can never contain anything, so it is not worth indexing.
|
||||||
|
if (rects.length > 0) {
|
||||||
|
out.push({
|
||||||
|
facet,
|
||||||
|
name,
|
||||||
|
type: regionNode.attrs.type || '',
|
||||||
|
priority,
|
||||||
|
parent: parentName,
|
||||||
|
rects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
|
||||||
|
*
|
||||||
|
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
|
||||||
|
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
|
||||||
|
* the facet itself and is dropped from `path`; `group` is the innermost
|
||||||
|
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
|
||||||
|
* reads better than "Level 1" when naming where a spawn is.
|
||||||
|
*/
|
||||||
|
function parseLocations(source, facetHint = '') {
|
||||||
|
const root = parseXml(source)
|
||||||
|
const landmarks = []
|
||||||
|
if (!root) return landmarks
|
||||||
|
|
||||||
|
for (const top of childrenNamed(root, 'parent')) {
|
||||||
|
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
|
||||||
|
// and is preferred over the display label inside the file, which is where
|
||||||
|
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
|
||||||
|
// build can fall back to matching the label if the file name resolves to
|
||||||
|
// nothing — a shard may well name its files differently from its facets.
|
||||||
|
landmarks.push(
|
||||||
|
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return landmarks
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLocations(top, facet, label) {
|
||||||
|
const out = []
|
||||||
|
walkLocations(top, facet, [], out)
|
||||||
|
for (const landmark of out) landmark.facetLabel = label
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkLocations(node, facet, path, out) {
|
||||||
|
for (const child of childrenNamed(node, 'child')) {
|
||||||
|
const name = child.attrs.name || ''
|
||||||
|
if (name === '') continue
|
||||||
|
out.push({
|
||||||
|
facet,
|
||||||
|
name,
|
||||||
|
group: path.length > 0 ? path[path.length - 1] : name,
|
||||||
|
path: [...path],
|
||||||
|
x: toInt(child.attrs.x),
|
||||||
|
y: toInt(child.attrs.y),
|
||||||
|
z: toInt(child.attrs.z),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const parent of childrenNamed(node, 'parent')) {
|
||||||
|
const name = parent.attrs.name || ''
|
||||||
|
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `Config/ChampionSpawns.xml` into champion altar records.
|
||||||
|
*
|
||||||
|
* This is the shard's *configured* champion roster — which altars exist, where,
|
||||||
|
* and which type each is pinned to. It is static content and distinct from the
|
||||||
|
* live `champ.update` feed the bridge already carries: this says "there is an
|
||||||
|
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
|
||||||
|
*
|
||||||
|
* A spawn with no `type` is randomised on every activation, which the site must
|
||||||
|
* render as "random" rather than as an empty type.
|
||||||
|
*/
|
||||||
|
function parseChampions(source) {
|
||||||
|
const root = parseXml(source)
|
||||||
|
const champions = []
|
||||||
|
if (!root) return champions
|
||||||
|
|
||||||
|
for (const spawnNode of childrenNamed(root, 'spawn')) {
|
||||||
|
const location = childrenNamed(spawnNode, 'location')[0]
|
||||||
|
const attrs = location ? location.attrs : {}
|
||||||
|
champions.push({
|
||||||
|
name: spawnNode.attrs.name || '',
|
||||||
|
group: spawnNode.attrs.group || '',
|
||||||
|
type: spawnNode.attrs.type || '',
|
||||||
|
randomType: !spawnNode.attrs.type,
|
||||||
|
facet: (attrs.map || '').trim(),
|
||||||
|
x: toInt(attrs.x),
|
||||||
|
y: toInt(attrs.y),
|
||||||
|
z: toInt(attrs.z),
|
||||||
|
radius: toInt(attrs.radius),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return champions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Placement ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_LANDMARK_RADIUS = 200
|
||||||
|
|
||||||
|
function inRect(x, y, rect) {
|
||||||
|
return (
|
||||||
|
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectArea(rect) {
|
||||||
|
return Math.max(1, rect.width) * Math.max(1, rect.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group parsed regions and landmarks by facet once, so the per-point resolve
|
||||||
|
* below is a scan of one facet instead of the whole world. With ~6,500 points
|
||||||
|
* and a few thousand rects this stays comfortably sub-second; there is no need
|
||||||
|
* for a spatial index and none is worth the complexity.
|
||||||
|
*/
|
||||||
|
function buildPlacementIndex(regions, landmarks) {
|
||||||
|
const byFacet = new Map()
|
||||||
|
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
|
||||||
|
// land in separate buckets — the failure that silently emptied the landmark
|
||||||
|
// bucket for Ter Mur and Tokuno.
|
||||||
|
const facet = (name) => {
|
||||||
|
const key = facetKey(name)
|
||||||
|
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
|
||||||
|
return byFacet.get(key)
|
||||||
|
}
|
||||||
|
for (const region of regions) facet(region.facet).regions.push(region)
|
||||||
|
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
|
||||||
|
return byFacet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a raw coordinate into a human place name.
|
||||||
|
*
|
||||||
|
* This is the transform the whole atlas exists for: it is what makes a row read
|
||||||
|
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. The highest-`priority` named region whose rect contains the point. Ties
|
||||||
|
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
|
||||||
|
* over the dungeon-wide rect it sits in.
|
||||||
|
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
|
||||||
|
* its group ("Covetous"), not the individual marker ("Level 1").
|
||||||
|
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
|
||||||
|
* without it the nearest landmark is always *some* landmark, however far,
|
||||||
|
* and open countryside would get labelled with a dungeon on the far side
|
||||||
|
* of the map.
|
||||||
|
*/
|
||||||
|
function resolveRegion(x, y, facetName, index, options = {}) {
|
||||||
|
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
|
||||||
|
const bucket = index.get(facetKey(facetName))
|
||||||
|
const result = { region: null, landmark: null, label: 'Wilderness' }
|
||||||
|
if (!bucket) return result
|
||||||
|
|
||||||
|
let best = null
|
||||||
|
let bestPriority = -Infinity
|
||||||
|
let bestArea = Infinity
|
||||||
|
for (const region of bucket.regions) {
|
||||||
|
for (const rect of region.rects) {
|
||||||
|
if (!inRect(x, y, rect)) continue
|
||||||
|
const area = rectArea(rect)
|
||||||
|
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
|
||||||
|
best = region
|
||||||
|
bestPriority = region.priority
|
||||||
|
bestArea = area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
result.region = best.name
|
||||||
|
result.label = best.name
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearest = null
|
||||||
|
let nearestDistance = Infinity
|
||||||
|
const limit = radius * radius
|
||||||
|
for (const landmark of bucket.landmarks) {
|
||||||
|
const dx = landmark.x - x
|
||||||
|
const dy = landmark.y - y
|
||||||
|
const distance = dx * dx + dy * dy
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearest = landmark
|
||||||
|
nearestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest && nearestDistance <= limit) {
|
||||||
|
result.landmark = nearest.group || nearest.name
|
||||||
|
result.label = result.landmark
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseXml,
|
||||||
|
parseObjects2,
|
||||||
|
parsePoints,
|
||||||
|
parseRegions,
|
||||||
|
parseLocations,
|
||||||
|
parseChampions,
|
||||||
|
buildPlacementIndex,
|
||||||
|
resolveRegion,
|
||||||
|
facetKey,
|
||||||
|
buildFacetIndex,
|
||||||
|
resolveFacetName,
|
||||||
|
slugify,
|
||||||
|
decodeEntities,
|
||||||
|
DEFAULT_LANDMARK_RADIUS,
|
||||||
|
}
|
||||||
336
server/utils/spawnAtlasSource.js
Normal file
336
server/utils/spawnAtlasSource.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// Spawn atlas — the filesystem layer over a ServUO tree.
|
||||||
|
//
|
||||||
|
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
|
||||||
|
// that touches a ServUO tree on disk, and it is shared by both callers:
|
||||||
|
//
|
||||||
|
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||||
|
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||||
|
//
|
||||||
|
// The shard's own files are the single source of truth. Nothing is precomputed
|
||||||
|
// and committed, because a shard's maps change over its lifetime — facets get
|
||||||
|
// added, replaced or renamed — and a snapshot in the repo would silently go
|
||||||
|
// stale against the world players actually see.
|
||||||
|
//
|
||||||
|
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
|
||||||
|
// the boot path hashes first and only parses when something actually changed.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const {
|
||||||
|
parsePoints,
|
||||||
|
parseRegions,
|
||||||
|
parseLocations,
|
||||||
|
parseChampions,
|
||||||
|
buildPlacementIndex,
|
||||||
|
buildFacetIndex,
|
||||||
|
resolveFacetName,
|
||||||
|
resolveRegion,
|
||||||
|
facetKey,
|
||||||
|
slugify,
|
||||||
|
} = require('./spawnAtlasParse')
|
||||||
|
|
||||||
|
const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||||
|
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||||
|
const SPAWNS_DIR = 'Spawns'
|
||||||
|
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||||
|
|
||||||
|
class AtlasSourceError extends Error {
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AtlasSourceError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reading ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sha256(text) {
|
||||||
|
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function listXml(dir) {
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||||
|
.sort()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIfPresent(file) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file, 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every atlas source file under `root`.
|
||||||
|
*
|
||||||
|
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
|
||||||
|
* tree-relative and forward-slashed so a hash map compares equal across
|
||||||
|
* platforms — the same tree read on Windows and Linux must produce the same
|
||||||
|
* fingerprint or every boot would look like a change.
|
||||||
|
*/
|
||||||
|
function readSources(root) {
|
||||||
|
if (!root || String(root).trim() === '') {
|
||||||
|
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = []
|
||||||
|
const push = (label, file) => {
|
||||||
|
const text = readIfPresent(file)
|
||||||
|
if (text === null) return false
|
||||||
|
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
|
||||||
|
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
|
||||||
|
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
|
||||||
|
if (spawnFiles.length === 0) {
|
||||||
|
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
|
||||||
|
}
|
||||||
|
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
|
||||||
|
|
||||||
|
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||||
|
|
||||||
|
return { files }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
|
||||||
|
*
|
||||||
|
* The boot path compares this against what was last imported and skips the
|
||||||
|
* parse entirely when it matches, which is the normal case on every restart
|
||||||
|
* that did not follow a map update.
|
||||||
|
*/
|
||||||
|
function hashSources(root) {
|
||||||
|
const { files } = readSources(root)
|
||||||
|
const hashes = {}
|
||||||
|
for (const file of files) hashes[file.label] = file.sha256
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
|
||||||
|
* files — a fixed misreading, a new field, a changed unit.
|
||||||
|
*
|
||||||
|
* Without it the hash gate is a trap: an install whose tree has not changed
|
||||||
|
* would keep serving what an older parser derived, indefinitely, because the
|
||||||
|
* only thing the boot path compares is the tree. The version is stored beside
|
||||||
|
* the source hashes and a mismatch counts as drift, so a deploy that corrects
|
||||||
|
* the parse actually reaches the data.
|
||||||
|
*
|
||||||
|
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||||
|
* seconds in the source, decided by `DelayInSec`).
|
||||||
|
*/
|
||||||
|
const PARSER_VERSION = 2
|
||||||
|
|
||||||
|
/** True when two source fingerprints describe the same tree. */
|
||||||
|
function sameSources(a, b) {
|
||||||
|
if (!a || !b) return false
|
||||||
|
const aKeys = Object.keys(a).sort()
|
||||||
|
const bKeys = Object.keys(b).sort()
|
||||||
|
if (aKeys.length !== bKeys.length) return false
|
||||||
|
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose one display spelling for a creature.
|
||||||
|
*
|
||||||
|
* Spawn files are not consistent about case — the same creature is `Lizardman`
|
||||||
|
* in one file and `lizardman` in another. Slugging collapses them correctly, but
|
||||||
|
* the display name would otherwise depend on file read order. Most frequent
|
||||||
|
* spelling wins; ties break toward more capitals, then alphabetically.
|
||||||
|
*/
|
||||||
|
function displayName(spellings) {
|
||||||
|
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||||
|
return [...spellings.entries()].sort((a, b) => {
|
||||||
|
if (b[1] !== a[1]) return b[1] - a[1]
|
||||||
|
const caps = capitals(b[0]) - capitals(a[0])
|
||||||
|
if (caps !== 0) return caps
|
||||||
|
return a[0].localeCompare(b[0])
|
||||||
|
})[0][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll spawn points up into per-type creature rows.
|
||||||
|
*
|
||||||
|
* `total` is the sum of each type's own max across every point that spawns it —
|
||||||
|
* how many of this creature the world holds at once. `facets` is a per-facet
|
||||||
|
* point count, so "where does this live" answers without touching the points.
|
||||||
|
*/
|
||||||
|
function aggregateCreatures(points) {
|
||||||
|
const creatures = new Map()
|
||||||
|
for (const point of points) {
|
||||||
|
for (const entry of point.types) {
|
||||||
|
const slug = slugify(entry.type)
|
||||||
|
if (slug === '') continue
|
||||||
|
let creature = creatures.get(slug)
|
||||||
|
if (!creature) {
|
||||||
|
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||||
|
creatures.set(slug, creature)
|
||||||
|
}
|
||||||
|
creature.total += entry.max
|
||||||
|
creature.points += 1
|
||||||
|
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||||
|
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...creatures.values()]
|
||||||
|
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
|
||||||
|
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a ServUO tree into the full atlas.
|
||||||
|
*
|
||||||
|
* Pure with respect to the database — it reads files and returns data; nothing
|
||||||
|
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||||
|
*/
|
||||||
|
function buildAtlas(root, options = {}) {
|
||||||
|
const { files } = readSources(root)
|
||||||
|
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||||
|
const source = {}
|
||||||
|
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||||
|
|
||||||
|
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
|
||||||
|
|
||||||
|
const rawLandmarks = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.label.startsWith('Data/Locations/')) continue
|
||||||
|
const basename = path.basename(file.label, '.xml')
|
||||||
|
rawLandmarks.push(...parseLocations(file.text, basename))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPoints = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.label.startsWith('Spawns/')) continue
|
||||||
|
rawPoints.push(...parsePoints(file.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The facet set is whatever THIS tree declares — never a built-in list. A
|
||||||
|
// shard may add facets, replace them outright, or rename them when its maps
|
||||||
|
// are updated, and the atlas has to follow without a code change. Spawn
|
||||||
|
// records and region definitions are the authority, because those are the
|
||||||
|
// names everything else is keyed on.
|
||||||
|
const facetIndex = buildFacetIndex([
|
||||||
|
...rawPoints.map((point) => point.facet),
|
||||||
|
...regions.map((region) => region.facet),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Landmark facets are then matched against that set, which is what absorbs the
|
||||||
|
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
|
||||||
|
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
|
||||||
|
const fromFile = resolveFacetName(landmark.facet, facetIndex)
|
||||||
|
const matchedFile = facetIndex.has(facetKey(fromFile))
|
||||||
|
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
|
||||||
|
return { ...landmark, facet: resolved || landmark.facet }
|
||||||
|
})
|
||||||
|
|
||||||
|
const placement = buildPlacementIndex(regions, landmarks)
|
||||||
|
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
|
||||||
|
|
||||||
|
const disabled = rawPoints.filter((point) => !point.running).length
|
||||||
|
const points = rawPoints
|
||||||
|
// A spawner switched off in-world produces nothing; advertising it would be
|
||||||
|
// a straight lie to a player planning a hunt.
|
||||||
|
.filter((point) => point.running)
|
||||||
|
// A spawner with no types is a placeholder — nothing to show.
|
||||||
|
.filter((point) => point.types.length > 0)
|
||||||
|
.map((point) => {
|
||||||
|
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
|
||||||
|
return {
|
||||||
|
name: point.name,
|
||||||
|
facet: point.facet,
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
width: point.width,
|
||||||
|
height: point.height,
|
||||||
|
range: point.range,
|
||||||
|
maxCount: point.maxCount,
|
||||||
|
minDelay: point.minDelay,
|
||||||
|
maxDelay: point.maxDelay,
|
||||||
|
todStart: point.todStart,
|
||||||
|
todEnd: point.todEnd,
|
||||||
|
todMode: point.todMode,
|
||||||
|
region: place.region,
|
||||||
|
landmark: place.landmark,
|
||||||
|
label: place.label,
|
||||||
|
types: point.types,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
|
||||||
|
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
|
||||||
|
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
|
||||||
|
return {
|
||||||
|
...champ,
|
||||||
|
facet,
|
||||||
|
slug: slugify(`${facet}-${champ.name}`),
|
||||||
|
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const creatures = aggregateCreatures(points)
|
||||||
|
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||||
|
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
parserVersion: PARSER_VERSION,
|
||||||
|
landmarkRadius: options.landmarkRadius ?? undefined,
|
||||||
|
counts: {
|
||||||
|
facets: facets.length,
|
||||||
|
points: points.length,
|
||||||
|
pointsDisabled: disabled,
|
||||||
|
creatures: creatures.length,
|
||||||
|
regions: regions.length,
|
||||||
|
landmarks: landmarks.length,
|
||||||
|
champions: champions.length,
|
||||||
|
unresolvedPoints: unresolved,
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
facets,
|
||||||
|
creatures,
|
||||||
|
regions,
|
||||||
|
landmarks,
|
||||||
|
champions,
|
||||||
|
points,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AtlasSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
readSources,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
buildAtlas,
|
||||||
|
aggregateCreatures,
|
||||||
|
displayName,
|
||||||
|
}
|
||||||
224
server/utils/uoLinkClient.js
Normal file
224
server/utils/uoLinkClient.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// ── uo-link sidecar REST client ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Server-side HTTP client for the uo-link sidecar (the bridge to the ServUO
|
||||||
|
// shard). Same shape as botInternalClient: never throws — every call returns
|
||||||
|
// { ok, data, status, error } so an admin poll or a public page never 500s just
|
||||||
|
// because the sidecar/shard is down or restarting.
|
||||||
|
//
|
||||||
|
// The base URL + shared-secret token come from the DB-backed uoLinkConfig
|
||||||
|
// (admin-managed, encrypted at rest) — NOT env vars, and the token is NEVER sent
|
||||||
|
// to the browser. Every request carries `Authorization: Bearer <token>` and
|
||||||
|
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
||||||
|
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
||||||
|
// token on every call.
|
||||||
|
|
||||||
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const log = require('../core').logger('uo-link-client')
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
|
||||||
|
const CONFIG_TTL_MS = 5000
|
||||||
|
|
||||||
|
let cachedConfig = null
|
||||||
|
let cachedAt = 0
|
||||||
|
|
||||||
|
// Read (and briefly cache) the connection config incl. decrypted token.
|
||||||
|
async function resolveConfig() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (cachedConfig && now - cachedAt < CONFIG_TTL_MS) return cachedConfig
|
||||||
|
cachedConfig = await uoLinkConfig.getWithToken()
|
||||||
|
cachedAt = now
|
||||||
|
return cachedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the cache after a save so the next call picks up new URL/token immediately.
|
||||||
|
function invalidateConfig() {
|
||||||
|
cachedConfig = null
|
||||||
|
cachedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core request. Returns { ok, data, status, error }. `ok` is true only on a 2xx
|
||||||
|
// with a parseable JSON body. Non-2xx responses still return their status + body
|
||||||
|
// so callers can distinguish 503 (shard restarting — transient) from 404.
|
||||||
|
async function call(path, { method = 'GET', body } = {}) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
// resolveConfig() decrypts the stored auth token, and decryption THROWS when the
|
||||||
|
// ciphertext can't be authenticated — SECRET_ENC_KEY was rotated, or a DB dump was
|
||||||
|
// restored into an environment keyed differently. It must stay INSIDE the try: out
|
||||||
|
// here it escaped `call()` entirely and 500'd every live-shard route (admin and
|
||||||
|
// player character/roster/vendor lookups, GET /admin/uo-link/config) instead of
|
||||||
|
// degrading to "shard unavailable". This module never throws — see the header.
|
||||||
|
let configResolved = false
|
||||||
|
try {
|
||||||
|
const config = await resolveConfig()
|
||||||
|
configResolved = true
|
||||||
|
if (!config || !config.baseUrl) {
|
||||||
|
return { ok: false, status: 0, error: 'uo-link is not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-UOLink-Version': String(config.protocol || 3),
|
||||||
|
}
|
||||||
|
if (config.token) headers.Authorization = `Bearer ${config.token}`
|
||||||
|
|
||||||
|
const res = await fetch(`${config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
let data = null
|
||||||
|
try {
|
||||||
|
data = await res.json()
|
||||||
|
} catch {
|
||||||
|
// Non-JSON (or empty) body — leave data null; status still reported.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) log.warn('uo-link rejected auth token (401)', { path })
|
||||||
|
if (res.status === 409) log.error('uo-link protocol mismatch (409)', { path, body: data })
|
||||||
|
return { ok: false, status: res.status, data, error: `sidecar responded ${res.status}` }
|
||||||
|
}
|
||||||
|
return { ok: true, status: res.status, data }
|
||||||
|
} catch (err) {
|
||||||
|
// A failure before the config resolved is a misconfiguration, not a flaky
|
||||||
|
// sidecar: log it loudly (and distinctly) so "the shard looks offline" doesn't
|
||||||
|
// silently mean "the token can no longer be decrypted".
|
||||||
|
if (!configResolved) {
|
||||||
|
log.error('uo-link config unreadable — is SECRET_ENC_KEY the key the stored token was encrypted with?', {
|
||||||
|
path,
|
||||||
|
message: err.message,
|
||||||
|
})
|
||||||
|
return { ok: false, status: 0, error: 'uo-link config unreadable' }
|
||||||
|
}
|
||||||
|
log.warn('uo-link call failed', { path, message: err.message })
|
||||||
|
return { ok: false, status: 0, error: err.message }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read queries ───────────────────────────────────────────────────────────
|
||||||
|
// Liveness (no auth required by the sidecar, but we send it anyway).
|
||||||
|
const health = () => call('/health')
|
||||||
|
const getCharBySerial = (serial) => call(`/char/serial/${encodeURIComponent(serial)}`)
|
||||||
|
const getCharBySlot = (account, slot) =>
|
||||||
|
call(`/char/${encodeURIComponent(account)}/${encodeURIComponent(slot)}`)
|
||||||
|
const getRoster = (account) => call(`/roster/${encodeURIComponent(account)}`)
|
||||||
|
const getVendors = (account) => call(`/vendors/${encodeURIComponent(account)}`)
|
||||||
|
|
||||||
|
// History / economy series — used for WS-reconnect backfill and public feeds.
|
||||||
|
function getHistory({ kind, limit = 100 } = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (kind) params.set('kind', kind)
|
||||||
|
if (limit) params.set('limit', String(limit))
|
||||||
|
const qs = params.toString()
|
||||||
|
const suffix = qs ? `?${qs}` : ''
|
||||||
|
return call(`/history${suffix}`)
|
||||||
|
}
|
||||||
|
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
|
||||||
|
// Live board / queue projections — snapshotted on WS (re)connect and served from
|
||||||
|
// our own store thereafter.
|
||||||
|
const getChamps = () => call('/champs')
|
||||||
|
const getPages = () => call('/pages')
|
||||||
|
// Protocol 2.0 board projections — same snapshot-on-connect pattern.
|
||||||
|
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')
|
||||||
|
// 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)}`)
|
||||||
|
// Protocol 3.0: the player-vendor market index. The one PAGED sidecar read — a
|
||||||
|
// whole-world market does not fit in a response — so it answers with
|
||||||
|
// `{ vendors, total, limit, offset }` and the caller walks it (see uoLinkSocket).
|
||||||
|
const getMarket = ({ limit = 200, offset = 0 } = {}) =>
|
||||||
|
call(`/market?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`)
|
||||||
|
|
||||||
|
// ── Commands ──────────────────────────────────────────────────────────────
|
||||||
|
const confirmLink = (code, websiteUserId) =>
|
||||||
|
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
||||||
|
const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`)
|
||||||
|
|
||||||
|
// Account provisioning (Protocol 2.0). createAccount provisions a game account and
|
||||||
|
// auto-links it to the website user in one step; `ip` is the END USER's browser IP
|
||||||
|
// (read from the request), which the shard needs for its per-IP account cap — the
|
||||||
|
// sidecar only sees our server. The password is hashed on the shard and never
|
||||||
|
// appears in any reply/event/log. unlinkAccount severs a game account's tie from
|
||||||
|
// the site side. `actor` is the staff/website id, recorded in the shard audit.
|
||||||
|
const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
|
||||||
|
call('/accounts/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip },
|
||||||
|
})
|
||||||
|
const unlinkAccount = ({ actor, account }) =>
|
||||||
|
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
|
||||||
|
const postTownCrier = ({ id, lines, durationSec }) =>
|
||||||
|
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||||
|
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
|
||||||
|
// in the in-game News window; re-posting the same id REPLACES it. `announce`
|
||||||
|
// (default true on the sidecar) controls whether the criers proclaim the title.
|
||||||
|
const postNews = ({ id, title, body, image, url, announce }) =>
|
||||||
|
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
|
||||||
|
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||||
|
// Every call carries `actor` — the website username of the staff member — set by
|
||||||
|
// the controller from the session, NEVER from the browser. The shard records it
|
||||||
|
// for attribution and echoes an admin.audit event back over the WS feed.
|
||||||
|
const adminKick = ({ actor, account, serial }) =>
|
||||||
|
call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
|
||||||
|
const adminBan = ({ actor, account, serial, durationSec, reason }) =>
|
||||||
|
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
||||||
|
const adminUnban = ({ actor, account }) =>
|
||||||
|
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
||||||
|
const adminBroadcast = ({ actor, text, hue }) =>
|
||||||
|
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
|
||||||
|
|
||||||
|
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||||
|
const respondPage = (pageId, { message, close }) =>
|
||||||
|
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||||
|
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
invalidateConfig,
|
||||||
|
health,
|
||||||
|
getCharBySerial,
|
||||||
|
getCharBySlot,
|
||||||
|
getRoster,
|
||||||
|
getVendors,
|
||||||
|
getHistory,
|
||||||
|
getEconomy,
|
||||||
|
getChamps,
|
||||||
|
getPages,
|
||||||
|
getGuilds,
|
||||||
|
getGovernors,
|
||||||
|
getHouses,
|
||||||
|
getPresence,
|
||||||
|
getRuleset,
|
||||||
|
getPoints,
|
||||||
|
getPointsBoard,
|
||||||
|
getMarket,
|
||||||
|
confirmLink,
|
||||||
|
linkLookup,
|
||||||
|
createAccount,
|
||||||
|
unlinkAccount,
|
||||||
|
postTownCrier,
|
||||||
|
deleteTownCrier,
|
||||||
|
postNews,
|
||||||
|
deleteNews,
|
||||||
|
adminKick,
|
||||||
|
adminBan,
|
||||||
|
adminUnban,
|
||||||
|
adminBroadcast,
|
||||||
|
respondPage,
|
||||||
|
closePage,
|
||||||
|
}
|
||||||
313
server/utils/uoLinkSocket.js
Normal file
313
server/utils/uoLinkSocket.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// ── uo-link WebSocket ingest client ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Long-lived client that connects to the sidecar's push-only WS feed and pumps
|
||||||
|
// every frame through the ingest dispatcher. This is the server's first
|
||||||
|
// outbound WebSocket. Lifecycle:
|
||||||
|
// • start() — connect if the config is enabled and has a token; verify the
|
||||||
|
// ws.hello protocol; backfill missed events via /history on every
|
||||||
|
// (re)connect (INSERT IGNORE dedupes the overlap); reconnect with
|
||||||
|
// capped backoff.
|
||||||
|
// • stop() — close the socket and stop reconnecting (graceful shutdown).
|
||||||
|
// Connection state is mirrored into uo_link_config (plugin_connected / status /
|
||||||
|
// last_event_at) so the admin panel and public status endpoint have live data.
|
||||||
|
|
||||||
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const uoLinkClient = require('./uoLinkClient')
|
||||||
|
const shardIngest = require('./shardIngest')
|
||||||
|
const shardState = require('../model/shardState/shardState.model')
|
||||||
|
const newsGump = require('./newsGump')
|
||||||
|
const log = require('../core').logger('uo-link-socket')
|
||||||
|
|
||||||
|
const BACKOFF_MIN_MS = 1000
|
||||||
|
const BACKOFF_MAX_MS = 30000
|
||||||
|
const BACKFILL_LIMIT = 500
|
||||||
|
|
||||||
|
let ws = null
|
||||||
|
let reconnectTimer = null
|
||||||
|
let backoff = BACKOFF_MIN_MS
|
||||||
|
let running = false // set by start()/stop(); guards auto-reconnect
|
||||||
|
let helloSeen = false
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
connected: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
lastConnectedAt: null,
|
||||||
|
reconnects: 0,
|
||||||
|
protocol: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(wsUrl, token) {
|
||||||
|
const sep = wsUrl.includes('?') ? '&' : '?'
|
||||||
|
return token ? `${wsUrl}${sep}token=${encodeURIComponent(token)}` : wsUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// One guarded board snapshot: fetch, verify `data[key]` is an array, hand it to
|
||||||
|
// `apply`, and (when given) log `label` with the row count. Isolated so a
|
||||||
|
// failed/absent board never aborts the rest of backfill — and so backfill()
|
||||||
|
// stays a flat sequence rather than nine repetitions of the same guard.
|
||||||
|
async function snapshot(fetchFn, key, apply, label) {
|
||||||
|
const res = await fetchFn()
|
||||||
|
if (!res.ok || !res.data || !Array.isArray(res.data[key])) return
|
||||||
|
await apply(res.data[key])
|
||||||
|
if (label) log.info(label, { count: res.data[key].length })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay events through the dispatcher oldest-first (history/economy arrive
|
||||||
|
// newest-first) so latest-wins state settles correctly.
|
||||||
|
async function ingestReversed(events) {
|
||||||
|
for (const ev of [...events].reverse()) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||||
|
}
|
||||||
|
async function ingestEach(events) {
|
||||||
|
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Market backfill ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The market is the only board that does not fit in one response, so /market is
|
||||||
|
// paged and this walks it. Two bounds, both deliberate:
|
||||||
|
//
|
||||||
|
// • MARKET_SNAPSHOT_MAX caps the walk. A pathological world (or a sidecar whose
|
||||||
|
// store was never pruned) must not be able to hang startup — backfill runs
|
||||||
|
// before the site is serving the live feed, so an unbounded loop here is
|
||||||
|
// downtime, not slowness.
|
||||||
|
// • The loop stops on a SHORT page as well as on `total`, because a concurrent
|
||||||
|
// sweep can shrink the index underneath the walk and paging to a stale total
|
||||||
|
// would spin.
|
||||||
|
//
|
||||||
|
// Vendors are upserted, never reconciled-by-replacement. A vendor absent from the
|
||||||
|
// snapshot is absent because the sidecar dropped it on vendor.listing.remove —
|
||||||
|
// which our own ingest already processed — so clearing the table first would only
|
||||||
|
// create a window where the market page is empty.
|
||||||
|
const MARKET_SNAPSHOT_MAX = 5000
|
||||||
|
const MARKET_PAGE = 200
|
||||||
|
|
||||||
|
async function backfillMarket() {
|
||||||
|
let offset = 0
|
||||||
|
let seen = 0
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const res = await uoLinkClient.getMarket({ limit: MARKET_PAGE, offset })
|
||||||
|
if (!res.ok || !res.data || !Array.isArray(res.data.vendors)) return
|
||||||
|
|
||||||
|
const page = res.data.vendors
|
||||||
|
if (page.length === 0) break
|
||||||
|
|
||||||
|
await ingestEach(page)
|
||||||
|
seen += page.length
|
||||||
|
offset += page.length
|
||||||
|
|
||||||
|
if (page.length < MARKET_PAGE) break
|
||||||
|
if (seen >= MARKET_SNAPSHOT_MAX) {
|
||||||
|
log.warn('market snapshot truncated at the safety cap', {
|
||||||
|
cap: MARKET_SNAPSHOT_MAX,
|
||||||
|
total: res.data.total,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (Number.isFinite(res.data.total) && offset >= res.data.total) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen > 0) log.info('snapshotted player-vendor market from /market', { count: seen })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull recent events from the sidecar's own store and replay them through the
|
||||||
|
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
||||||
|
// make this idempotent, so overlap with what we already stored is harmless.
|
||||||
|
async function backfill() {
|
||||||
|
try {
|
||||||
|
await snapshot(() => uoLinkClient.getHistory({ limit: BACKFILL_LIMIT }), 'events', ingestReversed, 'backfilled events from /history')
|
||||||
|
await snapshot(() => uoLinkClient.getEconomy(200), 'series', ingestReversed)
|
||||||
|
|
||||||
|
// Champ board + help-page queue have no replay stream — snapshot the
|
||||||
|
// authoritative current state directly (the sidecar guide's advice for both),
|
||||||
|
// reconciling our tables to it so a stale row from before a disconnect can't
|
||||||
|
// linger. Live champ.*/page.* deltas keep them fresh thereafter.
|
||||||
|
await snapshot(() => uoLinkClient.getChamps(), 'spawns', (s) => shardState.replaceChamps(s), 'snapshotted champ board from /champs')
|
||||||
|
await snapshot(() => uoLinkClient.getPages(), 'pages', (p) => shardState.replacePages(p), 'snapshotted help-page queue from /pages')
|
||||||
|
|
||||||
|
// ── Protocol 2.0 boards ──────────────────────────────────────────────
|
||||||
|
// Same as champs/pages: snapshot the authoritative current state and
|
||||||
|
// reconcile our tables to it. Each call is independently guarded so a
|
||||||
|
// failed/absent board (e.g. no City Loyalty → empty /governors) never wipes
|
||||||
|
// another. Governors are NOT cleared before upsert (cities are fixed and the
|
||||||
|
// term-capture is idempotent, so a reconnect can't spawn spurious terms).
|
||||||
|
await snapshot(() => uoLinkClient.getGuilds(), 'guilds', (g) => shardState.replaceGuilds(g), 'snapshotted guild board from /guilds')
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// Routed through the dispatcher rather than straight to shardState, exactly as
|
||||||
|
// ingestEach does for the array-shaped boards: the two orders must produce the
|
||||||
|
// same stored frame, and calling setRuleset directly here made this a second
|
||||||
|
// write path that silently skipped the shard-name normalization the live frame
|
||||||
|
// gets. One writer, one set of rules.
|
||||||
|
const ruleset = await uoLinkClient.getRuleset()
|
||||||
|
if (ruleset.ok && ruleset.data && ruleset.data.ruleset) {
|
||||||
|
await shardIngest.ingest(ruleset.data.ruleset, { fromBackfill: true })
|
||||||
|
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')
|
||||||
|
|
||||||
|
await backfillMarket()
|
||||||
|
|
||||||
|
const presence = await uoLinkClient.getPresence()
|
||||||
|
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
||||||
|
await shardState.setPresence(presence.data)
|
||||||
|
log.info('snapshotted online population from /online', { count: presence.data.count })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-assert our published news into the in-game Town Cryer News gump. The
|
||||||
|
// website is the source of truth; this reconciles the gump on every
|
||||||
|
// (re)connect (and recovers any article whose original live push failed).
|
||||||
|
// Silent (announce:false) so a reconnect never re-proclaims old news.
|
||||||
|
await newsGump.reassertAll()
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('backfill failed (continuing on live feed)', { message: err.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (!running) return
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = setTimeout(connect, backoff)
|
||||||
|
log.info(`reconnecting in ${backoff}ms`)
|
||||||
|
backoff = Math.min(backoff * 2, BACKOFF_MAX_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!running) return
|
||||||
|
let config
|
||||||
|
try {
|
||||||
|
config = await uoLinkConfig.getWithToken()
|
||||||
|
} catch (err) {
|
||||||
|
log.error('could not read uo-link config', err)
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!config || !config.enabled || !config.wsUrl || !config.token) {
|
||||||
|
log.info('uo-link WS not started (disabled or missing url/token)')
|
||||||
|
running = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.protocol = config.protocol || 3
|
||||||
|
helloSeen = false
|
||||||
|
const url = buildUrl(config.wsUrl, config.token)
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('failed to open WS', err)
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('open', handleOpen)
|
||||||
|
ws.on('message', handleMessage)
|
||||||
|
ws.on('close', handleClose)
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
log.warn('uo-link WS error', { message: err.message })
|
||||||
|
// 'close' fires after 'error'; reconnect is scheduled there.
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WS lifecycle handlers, split out of connect() so it stays a flat setup path.
|
||||||
|
async function handleOpen() {
|
||||||
|
log.info('uo-link WS connected')
|
||||||
|
state.connected = true
|
||||||
|
state.lastConnectedAt = Date.now()
|
||||||
|
backoff = BACKOFF_MIN_MS
|
||||||
|
await uoLinkConfig.recordStatus({ status: 'connected', statusDetail: null, pluginConnected: true }).catch(() => {})
|
||||||
|
await backfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ws.hello frame: mark it seen and, on a protocol mismatch, record the error
|
||||||
|
// and close (we won't run against an incompatible sidecar).
|
||||||
|
async function handleHello(event) {
|
||||||
|
helloSeen = true
|
||||||
|
if (!event.protocol || event.protocol === state.protocol) return
|
||||||
|
log.error('uo-link protocol mismatch on ws.hello — closing', { expected: state.protocol, got: event.protocol })
|
||||||
|
await uoLinkConfig
|
||||||
|
.recordStatus({ status: 'error', statusDetail: `protocol mismatch: expected ${state.protocol}, got ${event.protocol}` })
|
||||||
|
.catch(() => {})
|
||||||
|
running = false
|
||||||
|
try {
|
||||||
|
ws.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessage(raw) {
|
||||||
|
let event
|
||||||
|
try {
|
||||||
|
event = JSON.parse(raw.toString())
|
||||||
|
} catch {
|
||||||
|
log.warn('dropping non-JSON WS frame')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === 'ws.hello') return handleHello(event)
|
||||||
|
if (event.kind === 'pong') return // sidecar heartbeat — ignore
|
||||||
|
|
||||||
|
state.lastEventAt = Number.isFinite(event.t) ? event.t : Date.now()
|
||||||
|
await shardIngest.ingest(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClose() {
|
||||||
|
state.connected = false
|
||||||
|
if (running) state.reconnects += 1
|
||||||
|
log.warn('uo-link WS closed')
|
||||||
|
await uoLinkConfig
|
||||||
|
.recordStatus({ status: running ? 'reconnecting' : 'disconnected', pluginConnected: false })
|
||||||
|
.catch(() => {})
|
||||||
|
ws = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin (or restart) the WS client. Idempotent — a running client is stopped
|
||||||
|
// first so a config save can re-point it at a new URL/token.
|
||||||
|
async function start() {
|
||||||
|
stop()
|
||||||
|
running = true
|
||||||
|
backoff = BACKOFF_MIN_MS
|
||||||
|
await connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the client and cancel any pending reconnect. Called on shutdown and
|
||||||
|
// before a restart.
|
||||||
|
function stop() {
|
||||||
|
running = false
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = null
|
||||||
|
if (ws) {
|
||||||
|
try {
|
||||||
|
ws.removeAllListeners()
|
||||||
|
ws.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
state.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ingestion stats for the admin panel.
|
||||||
|
function getState() {
|
||||||
|
return { ...state, running }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { start, stop, getState }
|
||||||
Reference in New Issue
Block a user