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>
321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
const core = require('../../core')
|
|
|
|
const { query } = core
|
|
|
|
// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
|
|
//
|
|
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
|
|
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
|
|
// writes to either. No foreign keys, consistent with every other shard_* table.
|
|
|
|
// Insert batch size for one vendor's listings. A shop is capped at
|
|
// MarketMaxListings (250 by default) on the shard side, so in practice this is
|
|
// one batch — it exists for the operator who raised that cap.
|
|
const BATCH = 500
|
|
|
|
// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
|
|
// query — they are special to LIKE itself — so a search for "50% off" would
|
|
// otherwise match everything containing "50" and a search for "_" would match
|
|
// every single-character name. Escaped with a backslash, which is MariaDB's
|
|
// default LIKE escape (no ESCAPE clause needed).
|
|
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
|
|
|
/**
|
|
* Replace one vendor's whole row and listing set, in one transaction.
|
|
*
|
|
* Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
|
|
* that vendor: the shard's sweep only emits a shop whose contents, prices or
|
|
* location moved, and when it does it sends the whole shop. Reconciling it item
|
|
* by item would be more code for the same result and would leave sold items
|
|
* behind on any path the reconciliation missed.
|
|
*
|
|
* All-or-nothing matters here for a specific reason: the two writes are "the
|
|
* shop" and "what is in it", and a failure between them leaves a shop advertising
|
|
* an inventory it no longer has (or none at all) — visibly wrong on the page, and
|
|
* indistinguishable from a genuinely empty shop.
|
|
*/
|
|
async function replaceVendor(vendor, items) {
|
|
const conn = await core.pool.getConnection()
|
|
try {
|
|
await conn.beginTransaction()
|
|
|
|
await conn.query(
|
|
`INSERT INTO shard_vendors
|
|
(serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
|
|
item_count, item_total, truncated, t,
|
|
fees_exempt, charge_per_period, funds, pay_interval_sec, next_pay_at,
|
|
periods_remaining, dismissal_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
|
|
owner_name = VALUES(owner_name), owner_acct = VALUES(owner_acct),
|
|
map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
|
z = VALUES(z), region = VALUES(region), house = VALUES(house),
|
|
item_count = VALUES(item_count), item_total = VALUES(item_total),
|
|
truncated = VALUES(truncated), t = VALUES(t),
|
|
-- Protocol 5. Written back unconditionally, INCLUDING when they are null:
|
|
-- a shard downgraded to a pre-v5 overlay stops sending the fees object, and
|
|
-- leaving the last v5 values in place would leave a dismissal date standing
|
|
-- that nothing is maintaining any more. A stale deadline is worse than none.
|
|
fees_exempt = VALUES(fees_exempt), charge_per_period = VALUES(charge_per_period),
|
|
funds = VALUES(funds), pay_interval_sec = VALUES(pay_interval_sec),
|
|
next_pay_at = VALUES(next_pay_at), periods_remaining = VALUES(periods_remaining),
|
|
dismissal_at = VALUES(dismissal_at),
|
|
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
|
|
-- MariaDB does not fire that when every column is written back
|
|
-- unchanged, and a shop that is re-published identically is still
|
|
-- FRESHLY CONFIRMED. Without this the staleness banner would age a
|
|
-- perfectly current shop forever.
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
[
|
|
vendor.serial,
|
|
vendor.shopName ?? null,
|
|
vendor.ownerSerial ?? null,
|
|
vendor.ownerName ?? null,
|
|
vendor.ownerAcct ?? null,
|
|
vendor.map ?? null,
|
|
Number.isFinite(vendor.x) ? vendor.x : null,
|
|
Number.isFinite(vendor.y) ? vendor.y : null,
|
|
Number.isFinite(vendor.z) ? vendor.z : null,
|
|
vendor.region ?? null,
|
|
vendor.house ?? null,
|
|
items.length,
|
|
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
|
vendor.truncated ? 1 : 0,
|
|
Number.isFinite(vendor.t) ? vendor.t : null,
|
|
vendor.feesExempt ? 1 : 0,
|
|
Number.isFinite(vendor.chargePerPeriod) ? vendor.chargePerPeriod : null,
|
|
Number.isFinite(vendor.funds) ? vendor.funds : null,
|
|
Number.isFinite(vendor.payIntervalSec) ? vendor.payIntervalSec : null,
|
|
vendor.nextPayAt ?? null,
|
|
Number.isFinite(vendor.periodsRemaining) ? vendor.periodsRemaining : null,
|
|
vendor.dismissalAt ?? null,
|
|
],
|
|
)
|
|
|
|
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])
|
|
|
|
const rows = items.map((i) => [
|
|
vendor.serial,
|
|
i.serial,
|
|
i.itemId,
|
|
i.hue,
|
|
i.amount,
|
|
i.price,
|
|
i.name,
|
|
i.cliloc,
|
|
i.displayName,
|
|
i.child ? 1 : 0,
|
|
])
|
|
|
|
for (let i = 0; i < rows.length; i += BATCH) {
|
|
await conn.batch(
|
|
`INSERT INTO shard_vendor_items
|
|
(vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
rows.slice(i, i + BATCH),
|
|
)
|
|
}
|
|
|
|
await conn.commit()
|
|
return { items: rows.length }
|
|
} catch (err) {
|
|
await conn.rollback().catch(() => {})
|
|
throw err
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
/** Drop one vendor and its listings (vendor.listing.remove). */
|
|
async function removeVendor(serial) {
|
|
const conn = await core.pool.getConnection()
|
|
try {
|
|
await conn.beginTransaction()
|
|
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
|
|
await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
|
|
await conn.commit()
|
|
} catch (err) {
|
|
await conn.rollback().catch(() => {})
|
|
throw err
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
// ── Search ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
|
|
// kryss and for how much" is the question, and answering it per vendor would make
|
|
// the caller flatten the shops back out. The vendor's columns ride along on the
|
|
// join so a result row is self-contained.
|
|
|
|
function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
|
|
const where = ['i.price > 0']
|
|
const params = []
|
|
|
|
if (q) {
|
|
// Both the resolved display name and the item's own literal, because an item
|
|
// with a player-set name (most of what is actually worth searching for on a
|
|
// player-run shard) may have a generic cliloc.
|
|
where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
|
|
params.push(likeTerm(q), likeTerm(q))
|
|
}
|
|
if (Number.isFinite(minPrice)) {
|
|
where.push('i.price >= ?')
|
|
params.push(minPrice)
|
|
}
|
|
if (Number.isFinite(maxPrice)) {
|
|
where.push('i.price <= ?')
|
|
params.push(maxPrice)
|
|
}
|
|
if (Number.isFinite(itemId)) {
|
|
where.push('i.item_id = ?')
|
|
params.push(itemId)
|
|
}
|
|
if (map) {
|
|
where.push('v.map = ?')
|
|
params.push(map)
|
|
}
|
|
if (region) {
|
|
where.push('v.region = ?')
|
|
params.push(region)
|
|
}
|
|
|
|
return { sql: `WHERE ${where.join(' AND ')}`, params }
|
|
}
|
|
|
|
// Whitelisted, because this interpolates into the statement. `recent` sorts by
|
|
// the vendor's freshness, which is the only way to see what has just been listed
|
|
// on a shard whose sweep is minutes wide.
|
|
const SORTS = {
|
|
price_asc: 'i.price ASC, i.id ASC',
|
|
price_desc: 'i.price DESC, i.id ASC',
|
|
recent: 'v.updated_at DESC, i.id ASC',
|
|
}
|
|
|
|
async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
|
|
const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
|
|
const order = SORTS[sort] || SORTS.price_asc
|
|
|
|
const rows = await query(
|
|
`SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
|
|
v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
|
|
v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
|
|
FROM shard_vendor_items i
|
|
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
|
${sql}
|
|
ORDER BY ${order}
|
|
LIMIT ? OFFSET ?`,
|
|
[...params, limit, offset],
|
|
)
|
|
|
|
const counted = await query(
|
|
`SELECT COUNT(*) AS n
|
|
FROM shard_vendor_items i
|
|
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
|
${sql}`,
|
|
params,
|
|
)
|
|
|
|
return { rows, total: Number(counted[0]?.n) || 0 }
|
|
}
|
|
|
|
async function getVendor(serial) {
|
|
const rows = await query(
|
|
`SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
|
item_count, item_total, truncated, t, updated_at
|
|
FROM shard_vendors WHERE serial = ?`,
|
|
[serial],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
async function listVendorItems(serial, { limit, offset }) {
|
|
return query(
|
|
`SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
|
|
FROM shard_vendor_items
|
|
WHERE vendor_serial = ?
|
|
ORDER BY price ASC, id ASC
|
|
LIMIT ? OFFSET ?`,
|
|
[serial, limit, offset],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What the market page's header needs: how big the index is, and how stale it may
|
|
* be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
|
|
* be a full cycle behind, and the page says so rather than implying live prices.
|
|
*/
|
|
async function meta() {
|
|
const rows = await query(
|
|
`SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
|
|
FROM shard_vendors`,
|
|
)
|
|
const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
|
|
return {
|
|
vendors: Number(rows[0]?.vendors) || 0,
|
|
items: Number(items[0]?.n) || 0,
|
|
staleAt: rows[0]?.stale_at || null,
|
|
freshAt: rows[0]?.fresh_at || null,
|
|
}
|
|
}
|
|
|
|
/** The distinct facets and regions holding vendors — drives the page's filters. */
|
|
async function listPlaces() {
|
|
const maps = await query(
|
|
'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
|
|
)
|
|
const regions = await query(
|
|
'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
|
|
)
|
|
return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
|
|
}
|
|
|
|
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* One page of listings whose name still needs resolving, for the bulk pass that
|
|
* runs after a cliloc import.
|
|
*
|
|
* Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
|
|
* scanning, and an OFFSET walk over a table being rewritten skips rows. Every
|
|
* row with a cliloc is re-read, not just the unresolved ones, because an import
|
|
* can also CHANGE a name — a shard overlay relabelling a stock item is the whole
|
|
* reason overlays exist.
|
|
*/
|
|
async function listResolvableItems(after, limit) {
|
|
return query(
|
|
`SELECT id, cliloc, name, display_name
|
|
FROM shard_vendor_items
|
|
WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
|
|
ORDER BY id
|
|
LIMIT ?`,
|
|
[after, limit],
|
|
)
|
|
}
|
|
|
|
/** Write back a batch of re-resolved display names. */
|
|
async function updateDisplayNames(pairs) {
|
|
if (pairs.length === 0) return 0
|
|
const conn = await core.pool.getConnection()
|
|
try {
|
|
await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
|
|
return pairs.length
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
replaceVendor,
|
|
removeVendor,
|
|
searchListings,
|
|
getVendor,
|
|
listVendorItems,
|
|
meta,
|
|
listPlaces,
|
|
listResolvableItems,
|
|
updateDisplayNames,
|
|
likeTerm,
|
|
}
|