// ── 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('../../utils/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, }