The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.
server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.
Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.
The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.
One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.
Co-Authored-By: Claude <noreply@anthropic.com>
330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
// ── Player-vendor market index (Protocol 3.0 vendor.listing) ───────────────
|
|
//
|
|
// The shard-wide shop index: what every player vendor is selling, for how much,
|
|
// and where it is standing. This is the website's half of the search the in-game
|
|
// Vendor Search gump offers — the same data, the same opt-out, reachable without
|
|
// logging in to the game.
|
|
//
|
|
// Ingest is per-vendor and authoritative: the shard's round-robin sweep emits one
|
|
// `vendor.listing` frame per shop whose contents, prices or location moved, and
|
|
// the frame is the whole shop (see docs/link/v3.md §8 and BridgeMarket.cs). This
|
|
// module normalizes it into shard_vendors + shard_vendor_items and, crucially,
|
|
// resolves each listing's cliloc to a DISPLAY NAME on the way in — a search for
|
|
// "kryss" is a search over names, and the shard only ever sends numbers.
|
|
|
|
const db = require('./shardMarket.db')
|
|
const clilocs = require('../shardClilocs/shardClilocs.model')
|
|
const log = require('../../core').logger('shard-market')
|
|
|
|
// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
|
|
// trusted, but it is a separately-versioned component: a frame from a plugin
|
|
// whose cap was raised (or a shard running modified scripts) must not be able to
|
|
// turn one ingest into an unbounded transaction.
|
|
const MAX_ITEMS_PER_VENDOR = 5000
|
|
|
|
// Column widths in schema.sql. Truncating here rather than letting MariaDB do it
|
|
// keeps the behavior the same in strict mode, where an over-length value is an
|
|
// ERROR and would fail the whole vendor rather than shortening one name.
|
|
const MAX_NAME = 160
|
|
const MAX_SHOP = 160
|
|
const MAX_OWNER = 64
|
|
const MAX_MAP = 40
|
|
const MAX_REGION = 80
|
|
const MAX_SERIAL = 20
|
|
|
|
const clip = (value, max) => {
|
|
if (value == null) return null
|
|
const s = String(value)
|
|
return s.length > max ? s.slice(0, max) : s
|
|
}
|
|
|
|
const int = (value, fallback = 0) => {
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
|
}
|
|
|
|
// ── Ingest ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Flatten one `vendor.listing` frame into the row shapes the DB layer wants.
|
|
*
|
|
* `location` arrives as a nested object rather than flat map/x/y/region, and that
|
|
* shape is load-bearing rather than cosmetic: the visibility projection matches
|
|
* literal JSON keys, so ONE `market.location` rule can hide a vendor's
|
|
* whereabouts only if `location` is a single key on both the live frame and the
|
|
* stored read model. Flattening it here for storage and re-nesting it on read is
|
|
* what keeps that true on both paths.
|
|
*
|
|
* Exported for tests — it is the part with rules in it, and it is pure.
|
|
*/
|
|
function flattenFrame(ev) {
|
|
const loc = (ev && ev.location) || {}
|
|
return {
|
|
serial: clip(ev.serial, MAX_SERIAL),
|
|
shopName: clip(ev.shopName, MAX_SHOP),
|
|
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
|
ownerName: clip(ev.ownerName, MAX_OWNER),
|
|
map: clip(loc.map, MAX_MAP),
|
|
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
|
|
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
|
|
z: Number.isFinite(loc.z) ? Math.trunc(loc.z) : null,
|
|
region: clip(loc.region, MAX_REGION),
|
|
house: clip(loc.house, MAX_SHOP),
|
|
// What the SHOP holds, which is not what the frame carries when it was
|
|
// truncated. Kept apart so the page can say "showing 250 of 3,104" rather
|
|
// than presenting a partial shop as a complete one.
|
|
itemTotal: int(ev.total, int(ev.count, 0)),
|
|
truncated: ev.truncated === true,
|
|
t: Number.isFinite(ev.t) ? ev.t : null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve each listing's display name.
|
|
*
|
|
* Order of preference is the item's own literal `name` first, then the cliloc.
|
|
* That is the opposite of what "resolve the id" suggests and it is right: a
|
|
* literal name only exists because a player set one ("Bob's vanquishing kryss"),
|
|
* and it is strictly more specific than the generic cliloc the item still
|
|
* carries.
|
|
*
|
|
* One batched lookup per frame rather than per item; `resolveMany` is cached and
|
|
* never throws, so a cliloc table that is missing entirely just leaves
|
|
* `displayName` null and the page renders item ids, exactly as it did before the
|
|
* table existed.
|
|
*/
|
|
async function shapeItems(ev) {
|
|
const raw = Array.isArray(ev.items) ? ev.items.slice(0, MAX_ITEMS_PER_VENDOR) : []
|
|
|
|
const wanted = raw
|
|
.map((i) => int(i && i.cliloc, 0))
|
|
.filter((n) => n > 0)
|
|
|
|
const names = await clilocs.resolveMany(wanted)
|
|
|
|
return raw
|
|
.filter((i) => i && i.serial)
|
|
.map((i) => {
|
|
const literal = clip(i.name, MAX_NAME)
|
|
const cliloc = int(i.cliloc, 0) || null
|
|
return {
|
|
serial: clip(i.serial, MAX_SERIAL),
|
|
itemId: int(i.itemId, 0),
|
|
hue: int(i.hue, 0),
|
|
amount: int(i.amount, 1),
|
|
price: int(i.price, 0),
|
|
name: literal,
|
|
cliloc,
|
|
displayName: literal || (cliloc ? clip(names.get(cliloc) ?? null, MAX_NAME) : null),
|
|
child: i.child === true,
|
|
}
|
|
})
|
|
// Unpriced rows are inventory, not listings. The shard already drops them;
|
|
// this is the same rule enforced where the table is written, so a plugin that
|
|
// stops enforcing it cannot put un-buyable rows on the market page.
|
|
.filter((i) => i.price > 0)
|
|
}
|
|
|
|
/** Ingest one `vendor.listing` frame. */
|
|
async function upsertVendor(ev) {
|
|
if (!ev || !ev.serial) return
|
|
const vendor = flattenFrame(ev)
|
|
const items = await shapeItems(ev)
|
|
await db.replaceVendor(vendor, items)
|
|
}
|
|
|
|
/** Ingest one `vendor.listing.remove` frame. */
|
|
async function removeVendor(serial) {
|
|
if (!serial) return
|
|
await db.removeVendor(String(serial).slice(0, MAX_SERIAL))
|
|
}
|
|
|
|
// ── Read models ────────────────────────────────────────────────────────────
|
|
//
|
|
// `location` is re-nested (see flattenFrame) so the stored read model and the
|
|
// live wire frame present the same keys to the visibility projection.
|
|
|
|
const place = (r) => ({
|
|
map: r.map,
|
|
x: r.x,
|
|
y: r.y,
|
|
z: r.z,
|
|
region: r.region,
|
|
house: r.house,
|
|
})
|
|
|
|
// A listing as the search returns it: the item, plus enough of its shop to be
|
|
// actionable without a second request. `displayName` falls back to nothing rather
|
|
// than to a fabricated "Item 3922" — the client decides how to render an
|
|
// unresolved id, and inventing a name here would make it indistinguishable from
|
|
// a real one.
|
|
const shapeListing = (r) => ({
|
|
serial: r.serial,
|
|
itemId: r.item_id,
|
|
hue: r.hue,
|
|
amount: r.amount,
|
|
price: Number(r.price),
|
|
name: r.name,
|
|
cliloc: r.cliloc,
|
|
displayName: r.display_name,
|
|
child: !!r.child,
|
|
vendor: {
|
|
serial: r.vendor_serial,
|
|
shopName: r.shop_name,
|
|
ownerSerial: r.owner_serial,
|
|
ownerName: r.owner_name,
|
|
location: place(r),
|
|
updatedAt: r.updated_at,
|
|
},
|
|
})
|
|
|
|
const shapeVendor = (r) => ({
|
|
serial: r.serial,
|
|
shopName: r.shop_name,
|
|
ownerSerial: r.owner_serial,
|
|
ownerName: r.owner_name,
|
|
location: place(r),
|
|
count: r.item_count,
|
|
total: r.item_total,
|
|
truncated: !!r.truncated,
|
|
updatedAt: r.updated_at,
|
|
})
|
|
|
|
const shapeItem = (r) => ({
|
|
serial: r.serial,
|
|
itemId: r.item_id,
|
|
hue: r.hue,
|
|
amount: r.amount,
|
|
price: Number(r.price),
|
|
name: r.name,
|
|
cliloc: r.cliloc,
|
|
displayName: r.display_name,
|
|
child: !!r.child,
|
|
})
|
|
|
|
/**
|
|
* Search the index. Returns a page of LISTINGS (not vendors) plus the
|
|
* unpaginated total and the staleness stamp the page's banner needs.
|
|
*/
|
|
async function search({
|
|
q = '',
|
|
minPrice,
|
|
maxPrice,
|
|
itemId,
|
|
map = '',
|
|
region = '',
|
|
sort = 'price_asc',
|
|
limit = 50,
|
|
offset = 0,
|
|
} = {}) {
|
|
const { rows, total } = await db.searchListings({
|
|
q: q.trim(),
|
|
minPrice: Number.isFinite(minPrice) ? minPrice : undefined,
|
|
maxPrice: Number.isFinite(maxPrice) ? maxPrice : undefined,
|
|
itemId: Number.isFinite(itemId) ? itemId : undefined,
|
|
map: map.trim(),
|
|
region: region.trim(),
|
|
sort,
|
|
limit,
|
|
offset,
|
|
})
|
|
|
|
const info = await db.meta()
|
|
|
|
return {
|
|
listings: rows.map(shapeListing),
|
|
total,
|
|
limit,
|
|
offset,
|
|
// Repeated on every search response rather than left to a separate /meta
|
|
// call: the banner that says how old these prices are must age with the
|
|
// results it labels, and a client that fetched it once would keep showing a
|
|
// stamp from before the page it is looking at.
|
|
staleAt: info.staleAt,
|
|
vendors: info.vendors,
|
|
}
|
|
}
|
|
|
|
/** One shop and its listings. `null` when the index has never seen that serial. */
|
|
async function getVendor(serial, { limit = 250, offset = 0 } = {}) {
|
|
const row = await db.getVendor(serial)
|
|
if (!row) return null
|
|
const items = await db.listVendorItems(serial, { limit, offset })
|
|
return { ...shapeVendor(row), items: items.map(shapeItem) }
|
|
}
|
|
|
|
/** Index size, staleness, and the facet/region filter options. */
|
|
async function meta() {
|
|
const [info, places] = await Promise.all([db.meta(), db.listPlaces()])
|
|
return { ...info, ...places }
|
|
}
|
|
|
|
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
|
|
|
// Batch size for the post-import pass. Big enough that a 40k-row table is ~40
|
|
// round trips, small enough that a single batch is not a long-held connection.
|
|
const RESOLVE_BATCH = 1000
|
|
|
|
/**
|
|
* Re-resolve every listing's display name against the current cliloc table.
|
|
*
|
|
* Called after a cliloc import, and it has to be: the market's diff sweep will
|
|
* NOT re-send an unchanged shop just because the site learned what its items are
|
|
* called, so without this an operator who configures clilocs after the first
|
|
* market sweep sees item ids until every shop happens to change. That is the same
|
|
* class of staleness the spawn atlas avoids by re-parsing on boot — here the
|
|
* source of truth for names moved, not the data.
|
|
*
|
|
* Never throws. It is a cosmetic backfill on a table that is already serving; a
|
|
* failure means names stay as they were, which is exactly the pre-import state.
|
|
*/
|
|
async function refreshDisplayNames() {
|
|
let after = 0
|
|
let scanned = 0
|
|
let changed = 0
|
|
|
|
try {
|
|
for (;;) {
|
|
const rows = await db.listResolvableItems(after, RESOLVE_BATCH)
|
|
if (rows.length === 0) break
|
|
|
|
after = rows[rows.length - 1].id
|
|
scanned += rows.length
|
|
|
|
const names = await clilocs.resolveMany(rows.map((r) => Number(r.cliloc)))
|
|
|
|
const pairs = []
|
|
for (const row of rows) {
|
|
// The literal name still wins, so a re-resolution never overwrites a
|
|
// player-set name with the generic cliloc behind it.
|
|
const next = row.name
|
|
? clip(row.name, MAX_NAME)
|
|
: clip(names.get(Number(row.cliloc)) ?? null, MAX_NAME)
|
|
if (next !== row.display_name) pairs.push([next, row.id])
|
|
}
|
|
|
|
changed += await db.updateDisplayNames(pairs)
|
|
}
|
|
|
|
if (changed > 0) log.info('market display names refreshed', { scanned, changed })
|
|
return { scanned, changed }
|
|
} catch (err) {
|
|
log.warn('market display-name refresh failed', { message: err.message, scanned, changed })
|
|
return { scanned, changed, error: err.message }
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
upsertVendor,
|
|
removeVendor,
|
|
search,
|
|
getVendor,
|
|
meta,
|
|
refreshDisplayNames,
|
|
flattenFrame,
|
|
shapeItems,
|
|
shapeListing,
|
|
shapeVendor,
|
|
MAX_ITEMS_PER_VENDOR,
|
|
}
|