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>
352 lines
15 KiB
JavaScript
352 lines
15 KiB
JavaScript
// Ported from core in Phase 3 (MODULE_SYSTEM.md §2.7.1). One change runs through
|
|
// every moved test: core internals can no longer be stubbed by requiring them,
|
|
// because there are none to require — `../utils/db` and `../model/settings` do
|
|
// not exist here. What a test controls instead is the `ctx` core would have
|
|
// handed over, installed once by `test/_setup.js`, which is the seam the
|
|
// contract actually promises.
|
|
|
|
// Point the DB at a closed port BEFORE requiring anything that builds a pool.
|
|
// Nothing here reaches the database: these are the model's PURE parts — the
|
|
// flatten/shape rules the frame passes through on the way in and out — plus the
|
|
// visibility projection over the shapes they produce.
|
|
|
|
const { test, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const market = require('../model/shardMarket/shardMarket.model')
|
|
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
|
const clilocDb = require('../model/shardClilocs/shardClilocs.db')
|
|
const visibility = require('../utils/shardVisibility')
|
|
|
|
|
|
// Stand in for the cliloc table. Without this each unresolved lookup waits out
|
|
// the pool's 10s acquire timeout against the dead port — the model swallows the
|
|
// failure exactly as it would in production (an operator who never converted a
|
|
// cliloc file is in a supported state), so the RESULT is the same either way;
|
|
// this only stops the suite spending half a minute proving it.
|
|
const TABLE = new Map([[1023721, 'quarter staff']])
|
|
clilocDb.lookup = async (numbers) =>
|
|
numbers.filter((n) => TABLE.has(n)).map((n) => ({ number: n, text: TABLE.get(n) }))
|
|
|
|
const FRAME = {
|
|
kind: 'vendor.listing',
|
|
t: 1000,
|
|
serial: '0x40001234',
|
|
shopName: "Darrow's Bargains",
|
|
ownerSerial: '0x1A2B',
|
|
ownerName: 'Darrow',
|
|
location: { map: 'Trammel', x: 1421, y: 1699, z: 0, region: 'Britain', house: "Darrow's Villa" },
|
|
count: 2,
|
|
total: 2,
|
|
truncated: false,
|
|
items: [],
|
|
}
|
|
|
|
// ── flattenFrame ───────────────────────────────────────────────────────────
|
|
|
|
test('flattenFrame lifts the nested location into columns', () => {
|
|
const v = market.flattenFrame(FRAME)
|
|
assert.equal(v.serial, '0x40001234')
|
|
assert.equal(v.map, 'Trammel')
|
|
assert.equal(v.x, 1421)
|
|
assert.equal(v.region, 'Britain')
|
|
assert.equal(v.house, "Darrow's Villa")
|
|
})
|
|
|
|
// A vendor standing in the street has no house, and a frame from an older plugin
|
|
// may have no location at all. Neither is an error.
|
|
test('flattenFrame tolerates a missing location entirely', () => {
|
|
const v = market.flattenFrame({ serial: '0x1', shopName: null })
|
|
assert.equal(v.map, null)
|
|
assert.equal(v.x, null)
|
|
assert.equal(v.region, null)
|
|
assert.equal(v.house, null)
|
|
})
|
|
|
|
// `total` is what the SHOP holds; `count` is what the frame carried. A truncated
|
|
// shop must not report its published slice as its size, or the page says
|
|
// "showing 250 of 250" for a vendor holding three thousand stacks.
|
|
test('flattenFrame keeps the shop total separate from the published count', () => {
|
|
const v = market.flattenFrame({ ...FRAME, count: 250, total: 3104, truncated: true })
|
|
assert.equal(v.itemTotal, 3104)
|
|
assert.equal(v.truncated, true)
|
|
})
|
|
|
|
// An older plugin sends no `total`. Falling back to `count` is right — it is the
|
|
// only number available and it is correct whenever nothing was truncated.
|
|
test('flattenFrame falls back to count when total is absent', () => {
|
|
const v = market.flattenFrame({ ...FRAME, count: 7, total: undefined })
|
|
assert.equal(v.itemTotal, 7)
|
|
})
|
|
|
|
test('flattenFrame clips over-length strings rather than letting the insert fail', () => {
|
|
const v = market.flattenFrame({ ...FRAME, ownerName: 'x'.repeat(200) })
|
|
assert.equal(v.ownerName.length, 64)
|
|
})
|
|
|
|
// ── shapeItems ─────────────────────────────────────────────────────────────
|
|
//
|
|
// resolveMany never throws and, with no cliloc table reachable, resolves nothing
|
|
// — which is exactly the state of a shard whose operator never converted one, so
|
|
// these run against the real function rather than a stub.
|
|
|
|
test('shapeItems prefers the item\'s literal name over its cliloc', async () => {
|
|
const items = await market.shapeItems({
|
|
items: [{ serial: '0x1', itemId: 3922, price: 100, name: 'a shard sigil', cliloc: 1023721 }],
|
|
})
|
|
assert.equal(items[0].displayName, 'a shard sigil')
|
|
// The cliloc is kept regardless, so a later import can still re-resolve it.
|
|
assert.equal(items[0].cliloc, 1023721)
|
|
})
|
|
|
|
test('shapeItems resolves the cliloc when the item has no literal name', async () => {
|
|
const items = await market.shapeItems({
|
|
items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 1023721 }],
|
|
})
|
|
assert.equal(items[0].displayName, 'quarter staff')
|
|
})
|
|
|
|
// The supported state for a shard whose operator never converted a cliloc file:
|
|
// no name, not a fabricated one. Clients render the item id, exactly as they did
|
|
// before the table existed.
|
|
test('shapeItems leaves displayName null for an unknown cliloc', async () => {
|
|
const items = await market.shapeItems({
|
|
items: [{ serial: '0x1', itemId: 3922, price: 100, name: null, cliloc: 9999999 }],
|
|
})
|
|
assert.equal(items[0].displayName, null)
|
|
})
|
|
|
|
// Unpriced rows are inventory, not listings. The shard drops them too; enforcing
|
|
// it here as well means a plugin that stops doing so cannot put un-buyable rows
|
|
// on the market page.
|
|
test('shapeItems drops unpriced listings', async () => {
|
|
const items = await market.shapeItems({
|
|
items: [
|
|
{ serial: '0x1', itemId: 1, price: 0 },
|
|
{ serial: '0x2', itemId: 2, price: -1 },
|
|
{ serial: '0x3', itemId: 3, price: 5 },
|
|
],
|
|
})
|
|
assert.deepEqual(items.map((i) => i.serial), ['0x3'])
|
|
})
|
|
|
|
test('shapeItems caps a pathological frame', async () => {
|
|
const many = Array.from({ length: market.MAX_ITEMS_PER_VENDOR + 50 }, (_, i) => ({
|
|
serial: `0x${i}`,
|
|
itemId: 1,
|
|
price: 1,
|
|
}))
|
|
const items = await market.shapeItems({ items: many })
|
|
assert.equal(items.length, market.MAX_ITEMS_PER_VENDOR)
|
|
})
|
|
|
|
test('shapeItems tolerates a frame with no items array', async () => {
|
|
assert.deepEqual(await market.shapeItems({}), [])
|
|
})
|
|
|
|
// ── Visibility projection ──────────────────────────────────────────────────
|
|
//
|
|
// The regression that matters. Part A pre-wired `market.ownerName` and
|
|
// `market.location` before the frame existed, and the sibling rule it pre-wired
|
|
// for leaderboards (`characterName`) turned out to be INERT because projectValue
|
|
// matches literal JSON keys. These assert the market rules actually bite — on the
|
|
// read model AND on the wire frame, which is why both carry the same key names.
|
|
|
|
const config = visibility.compileDefaults()
|
|
|
|
const listing = market.shapeListing({
|
|
serial: '0x40012ABC',
|
|
item_id: 3922,
|
|
hue: 0,
|
|
amount: 1,
|
|
price: 25000,
|
|
name: null,
|
|
cliloc: 1023721,
|
|
display_name: 'quarter staff',
|
|
child: 0,
|
|
vendor_serial: '0x40001234',
|
|
shop_name: "Darrow's Bargains",
|
|
owner_serial: '0x1A2B',
|
|
owner_name: 'Darrow',
|
|
map: 'Trammel',
|
|
x: 1421,
|
|
y: 1699,
|
|
z: 0,
|
|
region: 'Britain',
|
|
house: "Darrow's Villa",
|
|
updated_at: new Date(0),
|
|
})
|
|
|
|
test('market defaults expose owner and location (they are already public in game)', () => {
|
|
const out = visibility.projectFeature('market', listing, 'anonymous', config)
|
|
assert.equal(out.vendor.ownerName, 'Darrow')
|
|
assert.equal(out.vendor.location.region, 'Britain')
|
|
})
|
|
|
|
test('tightening market.ownerName hides it from below that rung', () => {
|
|
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'staff' } } }
|
|
const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
|
|
const staff = visibility.projectFeature('market', listing, 'staff', tightened)
|
|
assert.equal('ownerName' in anon.vendor, false)
|
|
assert.equal(staff.vendor.ownerName, 'Darrow')
|
|
// The shop name is a separate field and must survive — hiding the owner is not
|
|
// the same as hiding the shop.
|
|
assert.equal(anon.vendor.shopName, "Darrow's Bargains")
|
|
})
|
|
|
|
// The whole reason `location` is one nested object: a single rule has to take the
|
|
// facet, the coordinates, the region and the house together. Five flat keys would
|
|
// be five rules that drift apart.
|
|
test('tightening market.location hides the whole location object at once', () => {
|
|
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, location: 'player' } } }
|
|
const anon = visibility.projectFeature('market', listing, 'anonymous', tightened)
|
|
const player = visibility.projectFeature('market', listing, 'player', tightened)
|
|
assert.equal('location' in anon.vendor, false)
|
|
assert.equal(player.vendor.location.map, 'Trammel')
|
|
})
|
|
|
|
// The same rules must bite on the LIVE frame, not just the stored read model —
|
|
// the market's SSE stream is off by default but an admin can turn it on, and a
|
|
// field rule that only worked on one of the two paths is exactly the leak §3.6.1
|
|
// records.
|
|
test('the same rules apply to the raw vendor.listing frame', () => {
|
|
const tightened = { ...config, market: { ...config.market, fields: { ...config.market.fields, ownerName: 'admin', location: 'admin' } } }
|
|
const out = visibility.projectFeature('market', FRAME, 'anonymous', tightened)
|
|
assert.equal('ownerName' in out, false)
|
|
assert.equal('location' in out, false)
|
|
assert.equal(out.shopName, "Darrow's Bargains")
|
|
})
|
|
|
|
// Rule 1 is not configurable and does not depend on the market rules at all: a
|
|
// frame that somehow carried an account name must never publish it.
|
|
test('acct and webId are stripped from a market payload regardless of config', () => {
|
|
const out = visibility.projectFeature(
|
|
'market',
|
|
{ serial: '0x1', ownerAcct: 'darrow', webId: '42', shopName: 'Shop' },
|
|
'staff',
|
|
config,
|
|
)
|
|
assert.equal('ownerAcct' in out, false)
|
|
assert.equal('webId' in out, false)
|
|
assert.equal(out.shopName, 'Shop')
|
|
})
|
|
|
|
// Both kinds must be attributed to a feature, or rule 2 makes them admin-only by
|
|
// omission — which would be a silent failure rather than a loud one.
|
|
test('both market kinds are mapped to the market feature', () => {
|
|
assert.equal(visibility.KIND_FEATURE.get('vendor.listing'), 'market')
|
|
assert.equal(visibility.KIND_FEATURE.get('vendor.listing.remove'), 'market')
|
|
})
|
|
|
|
// The market's live firehose is off by default (a page of whole vendor
|
|
// inventories is the site's biggest bandwidth item and no page needs it live),
|
|
// but the REST reads are unaffected — which is what `visibleKinds` ignoring the
|
|
// stream flag encodes.
|
|
test('market kinds are stream-suppressed by default but still readable', () => {
|
|
assert.equal(visibility.DEFAULT_STREAM_OFF.has('market'), true)
|
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', config), false)
|
|
assert.equal(visibility.PUBLIC_KINDS.has('vendor.listing'), false)
|
|
assert.ok(visibility.visibleKinds('anonymous', config).includes('vendor.listing'))
|
|
})
|
|
|
|
test('an admin who enables the stream gets the frames', () => {
|
|
const on = { ...config, market: { ...config.market, stream: true } }
|
|
assert.equal(visibility.kindVisibleTo('vendor.listing', 'anonymous', on), true)
|
|
})
|
|
|
|
// Guards the stub above against silently doing nothing: if the model stopped
|
|
// going through db.lookup, every shapeItems assertion would still "pass" by
|
|
// resolving nothing, which is also what a real miss looks like.
|
|
test('the cliloc resolver is the path shapeItems resolves through', async () => {
|
|
const found = await clilocs.resolveMany([1023721])
|
|
assert.equal(found.get(1023721), 'quarter staff')
|
|
})
|
|
|
|
// ── Protocol 5: owner account and fee state ────────────────────────────────
|
|
|
|
const V5_FEES = {
|
|
exempt: false,
|
|
newVendorSystem: true,
|
|
chargePerPeriod: 148,
|
|
funds: 2960,
|
|
holdGold: 2960,
|
|
bankAccount: 0,
|
|
payIntervalSec: 86400,
|
|
nextPayAt: '2026-09-01T00:00:00.000Z',
|
|
periodsRemaining: 20,
|
|
dismissalAt: '2026-09-21T00:00:00.000Z',
|
|
}
|
|
|
|
test('flattenFrame lifts ownerAcct, the field that makes a shop resolvable to a person', () => {
|
|
// ownerName has been on the frame since v3, but a character name joins to nothing:
|
|
// shard_account_links is keyed by the game ACCOUNT.
|
|
const row = market.flattenFrame({ ...FRAME, ownerAcct: 'darrow_acct', fees: V5_FEES })
|
|
assert.equal(row.ownerAcct, 'darrow_acct')
|
|
assert.equal(row.ownerName, 'Darrow', 'the character name is still carried too')
|
|
})
|
|
|
|
test('flattenFrame normalises the fee block, dates included', () => {
|
|
const row = market.flattenFrame({ ...FRAME, fees: V5_FEES })
|
|
assert.equal(row.feesExempt, false)
|
|
assert.equal(row.chargePerPeriod, 148)
|
|
assert.equal(row.funds, 2960)
|
|
assert.equal(row.payIntervalSec, 86400)
|
|
assert.equal(row.periodsRemaining, 20)
|
|
assert.ok(row.nextPayAt instanceof Date)
|
|
assert.equal(row.dismissalAt.toISOString(), '2026-09-21T00:00:00.000Z')
|
|
})
|
|
|
|
// The shard resolved dismissalAt against ServUO's two vendor systems, whose charge,
|
|
// funds and pay interval all differ. Re-deriving it here would be a second
|
|
// implementation of a rule that lives in PlayerVendor.PayTimer.
|
|
test('flattenFrame trusts the shard dismissal date instead of recomputing it', () => {
|
|
const row = market.flattenFrame({
|
|
...FRAME,
|
|
fees: { ...V5_FEES, dismissalAt: '2026-12-25T00:00:00.000Z' },
|
|
})
|
|
assert.equal(row.dismissalAt.toISOString(), '2026-12-25T00:00:00.000Z')
|
|
})
|
|
|
|
// A commission vendor has no pay timer and is never dismissed for fees. That is a
|
|
// different thing from having a long time left, and a surface rendering "never" has
|
|
// to be able to tell them apart.
|
|
test('an exempt vendor reports exempt with no schedule at all', () => {
|
|
const row = market.flattenFrame({ ...FRAME, fees: { exempt: true } })
|
|
assert.equal(row.feesExempt, true)
|
|
assert.equal(row.dismissalAt, null)
|
|
assert.equal(row.periodsRemaining, null)
|
|
assert.equal(row.chargePerPeriod, null)
|
|
})
|
|
|
|
// A pre-v5 overlay omits `fees` entirely, and a shard can be rolled back to one.
|
|
// Nulls have to mean "this shard has not told me", never "this vendor is broke" —
|
|
// the difference between silence and a false alarm in a rule that mails an owner.
|
|
test('a pre-v5 frame yields nulls, not zeroes', () => {
|
|
const row = market.flattenFrame(FRAME)
|
|
assert.equal(row.feesExempt, false)
|
|
for (const key of ['chargePerPeriod', 'funds', 'payIntervalSec', 'periodsRemaining']) {
|
|
assert.equal(row[key], null, `${key} must be null, not 0`)
|
|
}
|
|
assert.equal(row.nextPayAt, null)
|
|
assert.equal(row.dismissalAt, null)
|
|
assert.equal(row.ownerAcct, null)
|
|
})
|
|
|
|
test('an unparseable fee date is dropped rather than stored as an Invalid Date', () => {
|
|
const row = market.flattenFrame({
|
|
...FRAME,
|
|
fees: { ...V5_FEES, dismissalAt: 'next tuesday', nextPayAt: null },
|
|
})
|
|
assert.equal(row.dismissalAt, null)
|
|
assert.equal(row.nextPayAt, null)
|
|
assert.equal(row.funds, 2960, 'one bad field must not discard the rest of the block')
|
|
})
|
|
|
|
test('a malformed fees value is treated as absent, not as a crash', () => {
|
|
for (const fees of ['', 0, 'nope', []]) {
|
|
const row = market.flattenFrame({ ...FRAME, fees })
|
|
assert.equal(row.feesExempt, false)
|
|
assert.equal(row.dismissalAt, null)
|
|
}
|
|
})
|