Files
Module-uo/server/model/shardMarket/shardMarket.model.js
wtclaude f335531538
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 8m23s
feat(assets): item pictures on the marketplace and the character sheet (Phase 5)
Both places this site already knew an item's (ItemID, hue) and could only print
it as text now show the picture, hued the way the client would draw it. The
shard does the hueing: whether a hue repaints every pixel or only the grey ones
is a flag in `tiledata.mul`, which a browser has no way to read.

**Ingest warms; the route only serves** (org lead, 2026-09-11). A page never
waits on the shard and never causes a fetch -- it renders what is stored and
leaves out what is not, which is the state every install was in before this
phase. Fetching happens behind that, on a timer, from the keys the site's own
rows name. The alternative, fetching on first request, was rejected on one
number: the shard's asset plane serves ONE request at a time, so a URL that
fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot
and park an operator's own import behind it.

The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so
it is self-healing: a restart loses nothing, and a key stops being wanted the
moment the vendor row naming it is deleted. The in-memory hint set on top is only
for the character sheet, which is fetched live from the shard and stored nowhere
-- nothing on disk would ever name those keys.

Staleness without a manifest (§7): every row records the shard's `catalog` id, a
hash of the files that decide its bytes. A client patch changes it and a restart
does not, so "is this out of date?" is a per-row question -- and pictures nobody
looks at any more are simply never re-fetched, which is why this is lazy rather
than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body
catalogue's singleton, and a warm pass touching it would tell the body import
that a client it never looked at is unchanged.

A key the shard has no art for writes no row at all. An empty row would make the
key held and it would never be asked again -- including after the operator
patches in the graphic that was missing.

`assets.sources` now reports which families an overlay serves, so an overlay
older than phase 5 is one reported state with a sentence naming the fix, instead
of a refusal per pass forever with no picture ever appearing.

688 server tests pass (14 new); client builds; the frozen manifest regenerates
with one added route, all documented, no core URL moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:13:08 -05:00

386 lines
14 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 itemArt = require('../shardAssets/shardItemArt.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 MAX_ACCT = 120
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
}
// A wire timestamp -> a Date the DB layer can bind, or null. The shard emits ISO-8601
// (`DateTime.ToString("o")`); anything else is a plugin we do not recognise and is
// dropped rather than stored as an Invalid Date, which MariaDB rejects in strict mode
// and which would fail the whole vendor over one bad field.
const when = (value) => {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
// Protocol 5. The vendor's fee state, normalised out of the frame's `fees` object.
//
// Two things this deliberately does NOT do. It does not recompute `dismissalAt` from
// the parts -- the shard resolved it against ServUO's own two vendor systems (the
// charge, the funds and the interval all differ between them) and re-deriving it here
// would be a second implementation of a rule that lives in PlayerVendor.PayTimer. And
// it does not treat a missing `fees` object as zero: a pre-v5 overlay simply omits it,
// and nulls are how a v5 website says "this shard has not told me" rather than
// "this vendor is broke", which is the difference between silence and a false alarm.
const fees = (f) => {
if (!f || typeof f !== 'object') return { feesExempt: false, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
// A commission vendor has no pay timer and is never dismissed for fees. Reporting it
// as exempt with no schedule is not the same as reporting a very long one, and a
// surface that renders "never" must be able to tell them apart.
if (f.exempt === true) return { feesExempt: true, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
return {
feesExempt: false,
chargePerPeriod: Number.isFinite(f.chargePerPeriod) ? Math.trunc(f.chargePerPeriod) : null,
funds: Number.isFinite(f.funds) ? Math.trunc(f.funds) : null,
payIntervalSec: Number.isFinite(f.payIntervalSec) ? Math.trunc(f.payIntervalSec) : null,
nextPayAt: when(f.nextPayAt),
periodsRemaining: Number.isFinite(f.periodsRemaining) ? Math.trunc(f.periodsRemaining) : null,
dismissalAt: when(f.dismissalAt),
}
}
// ── 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),
// Protocol 5. The character name has been here since v3, but only the game
// ACCOUNT joins to shard_account_links -- so this is the field that makes a
// vendor row resolvable to a person at all.
ownerAcct: clip(ev.ownerAcct, MAX_ACCT),
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,
...fees(ev.fees),
}
}
/**
* 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)
// The listings name (itemId, hue) pairs, which are §5 asset keys (phase 5).
// Noticing them here is what makes the warm pass find a newly listed item's
// picture before anyone looks at the shop, rather than one page view later.
// A hint, never a queue — the pass derives its real set from this table, so a
// hint lost to a restart costs nothing.
itemArt.notice(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()
// Each listing gets `art`: the filename of the item's picture under
// uploads/items/, or null where this site does not hold one (phase 5). One
// query for the page, off the listing shape rather than the SQL, so the search
// itself stays the search and a picture lookup that fails costs a picture.
const listings = await itemArt.decorate(rows.map(shapeListing))
return {
listings,
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: await itemArt.decorate(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,
}