feat(modules): ctx additions and the post-hook registry (API 1.1.0)
Everything the extraction needed from core that ctx did not already offer. Additions only, so minor. ctx.activity.log, because an admin action a module performs has to land in core's one audit log or the trail has a hole exactly where a module operates the game -- a module keeping its own log would be a second place to look, which in practice means a place nobody looks. Write-only; reading the log is the admin panel's job and it spans every actor. ctx.users.getById, one function for one caller: the admin.users.detail slot router needs the user its prefix names. ctx.site.baseUrl, because a module has to build absolute links and §2.7 forbids it reading core's APP_BASE_URL -- a getter, not a captured string, so it cannot go stale against the env. ctx.middleware.rateLimit is core's makeLimiter, plus accountChangeLimiter handed over whole. The split is deliberate: a module states its own window and cap because it knows what its endpoints cost, and takes the plumbing from core so there is one express-rate-limit in the process and one place a breach is logged. accountChangeLimiter is shared policy -- core's /auth/me and /player/account sit behind the same counter -- so a module's account-change route has to land IN it rather than beside it. marketLimiter was UO policy living in core's file and leaves with the route it guards. registerPostHook is the fourth registry, and the last thing binding core to the module. Core's post controller called newsGump.syncPost directly: core's CMS naming a UO file. It now publishes what it already knows and a subscriber decides what to do with it. Not folded into registerAnnounceLeg, which fires on the same transition, because a leg is a one-shot DELIVERY with retry and classification while a post hook maintains idempotent STATE, runs on delete as well as save, and refreshes silently on an edit. Also fixes a real loader defect the extraction exposed: schema table names were matched against the RAW file, so a fragment whose header says "every CREATE TABLE carries IF NOT EXISTS" was rejected for a prefix violation on a table called `carries`. module-uo's fragment hit exactly that. Both scans now read split statements, which strip comments -- the same class of bug as a boundary check failing on its own documentation. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
// 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.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const ctrl = require('../src/router/v1/admin/usersShard.controller')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../src/model/shardState/shardState.model')
|
||||
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
||||
const { salesForAccounts } = require('../src/utils/shardSales')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
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.
|
||||
@@ -1,238 +0,0 @@
|
||||
// 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.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/router/v1/public/atlas.controller')
|
||||
const admin = require('../src/router/v1/admin/shardAtlas.controller')
|
||||
const atlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const visibility = require('../src/utils/shardVisibility')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// 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('../src/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)
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
ClilocFormatError,
|
||||
parseCliloc,
|
||||
parseClilocBinary,
|
||||
parseClilocText,
|
||||
isCompressedCliloc,
|
||||
displayText,
|
||||
isPlaceholderOnly,
|
||||
} = require('../src/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), '')
|
||||
})
|
||||
@@ -1,194 +0,0 @@
|
||||
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('../src/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), [])
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
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('../src/utils/uoLinkClient')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const newsGump = require('../src/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)
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
// 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.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const ctrl = require('../src/router/v1/public/shard.controller')
|
||||
const shardState = require('../src/model/shardState/shardState.model')
|
||||
const auth = require('../src/utils/auth')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
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`)
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/utils/shardBroadcast')
|
||||
const visibility = require('../src/utils/shardVisibility')
|
||||
const model = require('../src/model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
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(), [])
|
||||
})
|
||||
@@ -1,412 +0,0 @@
|
||||
// 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.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/router/v1/public/shard.controller')
|
||||
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
||||
const shardState = require('../src/model/shardState/shardState.model')
|
||||
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcast = require('../src/utils/shardBroadcast')
|
||||
const visibility = require('../src/utils/shardVisibility')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// 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('../src/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')
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/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])
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/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'])
|
||||
})
|
||||
@@ -1,111 +0,0 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Protocol 3.0 points.board routing. Same shape as shardIngest.ruleset.test.js:
|
||||
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
|
||||
// appended to the event log.
|
||||
function makeDeps() {
|
||||
const calls = { boards: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertPointsBoard: async (ev) => { calls.boards.push(ev) },
|
||||
// Present so any stray routing is a harmless no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop, setRuleset: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
const FRAME = {
|
||||
kind: 'points.board',
|
||||
t: 1000,
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
nameNumber: 1114938,
|
||||
maxPoints: 30000,
|
||||
showOnGump: true,
|
||||
players: 842,
|
||||
top: [
|
||||
{ rank: 1, serial: '0x1A2B', name: 'Darrow', points: 29500 },
|
||||
{ rank: 2, serial: '0x1A2C', name: 'Mireille', points: 21000 },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('points.board routes to upsertPointsBoard with the whole frame', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.boards.length, 1)
|
||||
const stored = deps.calls.boards[0]
|
||||
assert.equal(stored.system, 'QueensLoyalty')
|
||||
assert.equal(stored.nameNumber, 1114938)
|
||||
assert.equal(stored.players, 842)
|
||||
// The ranked list must survive intact — the read model serves it from the payload.
|
||||
assert.equal(stored.top.length, 2)
|
||||
assert.equal(stored.top[0].name, 'Darrow')
|
||||
})
|
||||
|
||||
// A board is state, not an event. The shard emits a frame every time anyone's
|
||||
// score moves the top ten, so logging would grow shard_events without bound for
|
||||
// something whose only interesting value is its latest version — the same call
|
||||
// guild.update already makes.
|
||||
test('points.board is NOT appended to the event log', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(shardIngest.LOGGED_KINDS.has('points.board'), false)
|
||||
})
|
||||
|
||||
test('points.board is broadcast (the leaderboards page updates live)', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
assert.equal(deps.calls.broadcast[0].kind, 'points.board')
|
||||
})
|
||||
|
||||
test('a backfilled points.board still stores but does not broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||
assert.equal(deps.calls.boards.length, 1)
|
||||
assert.equal(deps.calls.broadcast.length, 0)
|
||||
})
|
||||
|
||||
// Each system is its own row, so two systems must not collide — this is the whole
|
||||
// reason the frame is per-system rather than one board of everything.
|
||||
test('two systems are stored independently', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, system: 'CleanUpBritannia', nameString: null }, deps)
|
||||
assert.deepEqual(deps.calls.boards.map((b) => b.system), ['QueensLoyalty', 'CleanUpBritannia'])
|
||||
})
|
||||
|
||||
// A re-emitted board is an overwrite of one row, never an append.
|
||||
test('a repeated points.board overwrites rather than accumulating', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, t: 2000, players: 843 }, deps)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.boards.length, 2) // two writes...
|
||||
assert.equal(deps.calls.boards[1].system, 'QueensLoyalty') // ...of the same row
|
||||
})
|
||||
|
||||
// A model write that throws must not kill the feed.
|
||||
test('an upsertPointsBoard failure does not throw or stop the broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.shardState.upsertPointsBoard = async () => { throw new Error('db down') }
|
||||
const r = await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
})
|
||||
@@ -1,121 +0,0 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/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('../src/utils/shardBroadcast')
|
||||
assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false)
|
||||
assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false)
|
||||
})
|
||||
@@ -1,161 +0,0 @@
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../src/utils/shardIngest')
|
||||
|
||||
// Protocol 3.0 world.ruleset routing. Same shape as shardIngest.protocol2.test.js:
|
||||
// stubbed deps, asserting where the dispatcher sends the frame and whether it is
|
||||
// appended to the event log.
|
||||
function makeDeps() {
|
||||
const calls = { rulesetSet: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
setRuleset: async (ev) => { calls.rulesetSet.push(ev) },
|
||||
// Present so any stray routing is a harmless no-op.
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop,
|
||||
addEconomySample: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
const FRAME = {
|
||||
kind: 'world.ruleset',
|
||||
t: 1000,
|
||||
rev: '1a2b3c4d',
|
||||
shard: 'UOMysticmoon',
|
||||
expansion: 'EJ',
|
||||
systems: { cityLoyalty: true, vvv: true, factions: false },
|
||||
caps: { skill: 1000, totalSkill: 7000 },
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('world.ruleset routes to setRuleset with the whole frame', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||
const stored = deps.calls.rulesetSet[0]
|
||||
assert.equal(stored.rev, '1a2b3c4d')
|
||||
assert.equal(stored.expansion, 'EJ')
|
||||
// The nested blocks must survive intact — the read model serves the frame whole.
|
||||
assert.equal(stored.systems.vvv, true)
|
||||
assert.equal(stored.caps.totalSkill, 7000)
|
||||
})
|
||||
|
||||
// The shard re-emits world.ruleset on EVERY sidecar connect. Logging it would put
|
||||
// a duplicate row in shard_events per reconnect, and server.hello already marks
|
||||
// each of those — so this assertion is the guard on that decision.
|
||||
test('world.ruleset is NOT appended to the event log', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(shardIngest.LOGGED_KINDS.has('world.ruleset'), false)
|
||||
})
|
||||
|
||||
test('world.ruleset is broadcast (the rules page updates live)', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
assert.equal(deps.calls.broadcast[0].kind, 'world.ruleset')
|
||||
})
|
||||
|
||||
// A backfill replay must reach the store but must NOT re-animate the live ticker.
|
||||
test('a backfilled world.ruleset still stores but does not broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, { ...deps, fromBackfill: true })
|
||||
assert.equal(deps.calls.rulesetSet.length, 1)
|
||||
assert.equal(deps.calls.broadcast.length, 0)
|
||||
})
|
||||
|
||||
// A re-emitted identical ruleset is an overwrite, not an append: two ingests of
|
||||
// the same rev leave one stored frame's worth of state, never a growing log.
|
||||
test('a repeated world.ruleset overwrites rather than accumulating', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(FRAME, deps)
|
||||
await shardIngest.ingest({ ...FRAME, t: 2000 }, deps)
|
||||
assert.equal(deps.calls.appended.length, 0)
|
||||
assert.equal(deps.calls.rulesetSet.length, 2) // two writes...
|
||||
assert.equal(deps.calls.rulesetSet[1].rev, '1a2b3c4d') // ...of the same singleton
|
||||
})
|
||||
|
||||
// A model write that throws must not kill the feed — ingest swallows it and the
|
||||
// frame is still broadcast.
|
||||
test('a setRuleset failure does not throw or stop the broadcast', async () => {
|
||||
const deps = makeDeps()
|
||||
deps.shardState.setRuleset = async () => { throw new Error('db down') }
|
||||
const r = await shardIngest.ingest(FRAME, deps)
|
||||
assert.equal(r.logged, false)
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
})
|
||||
|
||||
// ── 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')
|
||||
})
|
||||
@@ -1,260 +0,0 @@
|
||||
// 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.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const market = require('../src/model/shardMarket/shardMarket.model')
|
||||
const clilocs = require('../src/model/shardClilocs/shardClilocs.model')
|
||||
const clilocDb = require('../src/model/shardClilocs/shardClilocs.db')
|
||||
const visibility = require('../src/utils/shardVisibility')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// 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')
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
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('../src/model/shardState/shardState.db')
|
||||
const model = require('../src/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()
|
||||
})
|
||||
@@ -1,253 +0,0 @@
|
||||
// Point the DB pool at a dead port before it's built; every db method is
|
||||
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const pool = require('../src/utils/db')
|
||||
after(() => pool.close())
|
||||
|
||||
// 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('../src/model/shardState/shardState.db')
|
||||
const shardState = require('../src/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
|
||||
})
|
||||
@@ -1,423 +0,0 @@
|
||||
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
||||
// Every DB call this suite would make is monkeypatched.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/utils/shardVisibility')
|
||||
const model = require('../src/model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -1,601 +0,0 @@
|
||||
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('../src/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)
|
||||
})
|
||||
@@ -1,396 +0,0 @@
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// ── 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'])
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
// Point the DB at a closed port BEFORE requiring the modules (they build the pool).
|
||||
// The uoLinkConfig model is monkeypatched so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
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('../src/utils/uoLinkClient')
|
||||
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user