The website half of the protocol-5 bump. Engagement Phase 10.
Schema — twelve columns and two indexes.
shard_houses gains next_stage, estimated_collapse, decay_period_sec and
dynamic_decay. estimated_collapse is nullable and stays null far more often than
not, deliberately: under dynamic decay ServUO draws each stage at random on entry,
so collapse is knowable only at IDOC. A null means "not knowable", never "not yet
read".
shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at.
owner_acct is the structural one — the table has carried owner_name since protocol
3, but a character name joins to nothing, and only the game account reaches
shard_account_links. Until now a vendor row named an owner the site could not
resolve to a person. dismissal_at + owner_acct are what let Phase 11's
uo.vendor.expiring find "vendors about to be dismissed" and turn each into a
person, without scanning every shop.
Ingest.
Both new field groups arrive NESTED and are flattened into columns on the way in,
then re-nested on the way out — the same trick shardMarket already uses for
`location`. That is not stylistic: the visibility projection matches literal JSON
keys, so the stored read model and the live wire frame have to spell a group
identically or one admin rule covers only one of the two paths. It also means a
field added inside a group later inherits the group's gate instead of defaulting to
visible; there is a test that adds an imaginary future fee field and asserts exactly
that.
Two write-back asymmetries, both load-bearing:
* ownerName is written ONLY when the frame carries one. house.update also writes
that column, from a different sweep, and a pre-v5 overlay's house.decay carries
no ownerName at all — coalescing to null would let every decay transition erase
a name the registry had already resolved.
* The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A
schedule is a claim about the future and goes stale on its own: roll a shard
back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value
is nothing. A dismissal date nobody is maintaining is worse than none.
dismissalAt is taken from the shard rather than recomputed. The shard resolved it
against ServUO's two vendor systems, whose charge, funds and pay interval all
differ; re-deriving it here would be a second implementation of PlayerVendor's own
rule.
Visibility — three classifications, each chosen rather than inherited.
* house.decay's `schedule` defaults to `anonymous`. The countdown IS the public
IDOC page's content and a house at IDOC is already announced in game. Listed
anyway so a shard that considers a precise collapse time an unfair advantage can
raise it — and one nested rule takes the whole schedule with it.
* vendor.listing's `fees` defaults to `admin`, the only default in the market
feature that does not reproduce prior behaviour, because there is no prior
behaviour to reproduce. Shop name, owner and location are already visible to any
player through the in-game Vendor Search gump, which is the argument for
publishing them. Held gold, daily charge and dismissal date are visible to the
OWNER only, on that vendor's own gump. Publishing them anonymously would be a
new disclosure and a targeting aid — which shops are about to be abandoned, and
how much coin is in each.
* account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds
an admin may widen, and there is no rung below admin that an IP plus an auth
verdict belongs on. The omission is the decision, and a test says so by name.
owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no
REST read model's column list — they exist for Phase 11's server-side trigger and
reach no client at all.
The pin, and the protocol-4 bug seen from the other side.
Both declaration sites go to 5 (the model constant and schema.sql's CREATE default),
plus the one-shot migration, guarded `protocol < 5` so an install that missed an
earlier step is carried the whole way.
The schema test used to assert `DEFAULT 4` at each site. That is exactly how
protocol 4 shipped with the emitters moved and one site left behind: every site
agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the
model, so the assertion is "the declarations AGREE", and the one-shot migration
test is written once against the current version instead of being hand-copied per
bump.
470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO
and the release sidecar.
Docs: RunicGateway/docs link/v5.md.
Co-Authored-By: Claude <noreply@anthropic.com>
372 lines
14 KiB
JavaScript
372 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 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)
|
|
}
|
|
|
|
/** 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,
|
|
}
|