feat(engagement): 26 shard triggers and the in-universe bodies — cutover 4 of 7 (edgemain) #26

Merged
whitlocktech merged 10 commits from edge into main 2026-09-01 13:59:04 +00:00
13 changed files with 534 additions and 19 deletions
Showing only changes of commit 75f9b27687 - Show all commits

View File

@@ -1,7 +1,7 @@
{ {
"id": "uo", "id": "uo",
"name": "Ultima Online", "name": "Ultima Online",
"version": "0.3.0", "version": "0.4.0",
"coreApi": "^1.3.0", "coreApi": "^1.3.0",
"server": "server/index.js", "server": "server/index.js",
"client": { "entry": "client/dist/entry.js" }, "client": { "entry": "client/dist/entry.js" },

View File

@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL, base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL, auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 4, protocol INT NOT NULL DEFAULT 5,
enabled TINYINT(1) NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL, status_detail VARCHAR(500) NULL,
@@ -727,3 +727,62 @@ ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL; ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL; ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`); ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);
-- ── Protocol 5 ───────────────────────────────────────────────────────────────
--
-- Three wire enrichments, bumped together (link/sidecar/src/main.rs, overlay.toml).
-- Two of them land as columns here; the third is a new event kind and needs none.
--
-- 1. house.decay's decay SCHEDULE. `shard_houses` could say what stage a house was
-- at and when it was last refreshed, but nothing about WHEN the next thing
-- happens — which is the only part a player can act on. `estimated_collapse` is
-- nullable and stays null far more often than not, deliberately: under dynamic
-- decay (Core.ML) ServUO draws each stage's duration at random when the stage is
-- entered, so collapse is exactly knowable only once the house is already at
-- IDOC. A null here means "not knowable", never "not yet read".
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS next_stage DATETIME NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS estimated_collapse DATETIME NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay_period_sec INT NULL;
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS dynamic_decay TINYINT(1) NULL;
-- 2. vendor.listing's owner account and fee state.
--
-- `owner_acct` is the one that matters structurally: the table has carried
-- `owner_name` since Protocol 3, but a character name is not an identity — only
-- the game ACCOUNT joins to shard_account_links, so until now a vendor row named
-- an owner the site could not resolve to a user.
--
-- The fee columns describe PlayerVendor.PayTimer's dismissal rule: at each tick
-- the charge is compared with the funds and the vendor is destroyed when the
-- charge wins. `dismissal_at` is that comparison resolved into an instant, which
-- is what any surface actually wants; the parts are kept alongside it so a
-- display can explain the number rather than only state it.
--
-- `fees_exempt` marks a commission vendor: it has no pay timer at all and is
-- never dismissed for fees, which is a different thing from having a long time
-- left and must not render as one.
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS owner_acct VARCHAR(120) NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS fees_exempt TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS charge_per_period INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS funds INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS pay_interval_sec INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS next_pay_at DATETIME NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS periods_remaining INT NULL;
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS dismissal_at DATETIME NULL;
-- Both of these exist for the same reader: the Phase 11 trigger that has to find
-- "vendors about to be dismissed" without scanning every shop, and the owner join
-- that turns one into a person.
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_dismissal (dismissal_at);
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_owner_acct (owner_acct);
-- 3. The protocol pin, one step on from the Protocol 4 block above and for exactly
-- the reasons it spells out. `protocol < 5` rather than `= 4`, so an install that
-- missed an earlier migration is carried the whole way; the one-shot marker is
-- written here in the module's own fragment, because core's schema is replayed in
-- full BEFORE any module fragment and a marker left in core would already exist
-- when this UPDATE read it.
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 5;
UPDATE uo_link_config SET protocol = 5
WHERE id = 1 AND protocol < 5
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');

View File

@@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) {
await conn.query( await conn.query(
`INSERT INTO shard_vendors `INSERT INTO shard_vendors
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house, (serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
item_count, item_total, truncated, t) item_count, item_total, truncated, t,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) 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), ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y), 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), z = VALUES(z), region = VALUES(region), house = VALUES(house),
item_count = VALUES(item_count), item_total = VALUES(item_total), item_count = VALUES(item_count), item_total = VALUES(item_total),
truncated = VALUES(truncated), t = VALUES(t), 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: -- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
-- MariaDB does not fire that when every column is written back -- MariaDB does not fire that when every column is written back
-- unchanged, and a shop that is re-published identically is still -- unchanged, and a shop that is re-published identically is still
@@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) {
vendor.shopName ?? null, vendor.shopName ?? null,
vendor.ownerSerial ?? null, vendor.ownerSerial ?? null,
vendor.ownerName ?? null, vendor.ownerName ?? null,
vendor.ownerAcct ?? null,
vendor.map ?? null, vendor.map ?? null,
Number.isFinite(vendor.x) ? vendor.x : null, Number.isFinite(vendor.x) ? vendor.x : null,
Number.isFinite(vendor.y) ? vendor.y : null, Number.isFinite(vendor.y) ? vendor.y : null,
@@ -70,6 +82,13 @@ async function replaceVendor(vendor, items) {
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length, Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
vendor.truncated ? 1 : 0, vendor.truncated ? 1 : 0,
Number.isFinite(vendor.t) ? vendor.t : null, 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,
], ],
) )

View File

@@ -31,6 +31,7 @@ const MAX_OWNER = 64
const MAX_MAP = 40 const MAX_MAP = 40
const MAX_REGION = 80 const MAX_REGION = 80
const MAX_SERIAL = 20 const MAX_SERIAL = 20
const MAX_ACCT = 120
const clip = (value, max) => { const clip = (value, max) => {
if (value == null) return null if (value == null) return null
@@ -43,6 +44,42 @@ const int = (value, fallback = 0) => {
return Number.isFinite(n) ? Math.trunc(n) : fallback 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 ───────────────────────────────────────────────────────────────── // ── Ingest ─────────────────────────────────────────────────────────────────
/** /**
@@ -64,6 +101,10 @@ function flattenFrame(ev) {
shopName: clip(ev.shopName, MAX_SHOP), shopName: clip(ev.shopName, MAX_SHOP),
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL), ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
ownerName: clip(ev.ownerName, MAX_OWNER), 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), map: clip(loc.map, MAX_MAP),
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null, x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null, y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
@@ -76,6 +117,7 @@ function flattenFrame(ev) {
itemTotal: int(ev.total, int(ev.count, 0)), itemTotal: int(ev.total, int(ev.count, 0)),
truncated: ev.truncated === true, truncated: ev.truncated === true,
t: Number.isFinite(ev.t) ? ev.t : null, t: Number.isFinite(ev.t) ? ev.t : null,
...fees(ev.fees),
} }
} }

View File

@@ -98,7 +98,12 @@ async function latestEconomy() {
// ── Houses / IDOC ──────────────────────────────────────────────────────── // ── Houses / IDOC ────────────────────────────────────────────────────────
const HOUSE_COLS = const HOUSE_COLS =
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' 'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' +
// Protocol 5's decay schedule. Added to the BASE column list rather than to
// HOUSE_REG_COLS because it arrives on house.decay, so a decay-only row -- one the
// registry sweep has never seen -- carries it too, and the public IDOC page reads
// exactly those rows.
', next_stage, estimated_collapse, decay_period_sec, dynamic_decay'
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields) const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)

View File

@@ -124,10 +124,39 @@ async function upsertHouse(data) {
built_on: data.builtOn ? new Date(data.builtOn) : null, built_on: data.builtOn ? new Date(data.builtOn) : null,
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0, is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
// Protocol 5. `ownerName` is written back only when the frame carries one, and
// that asymmetry is deliberate: house.update also writes this column, from a
// different sweep, and a pre-v5 overlay's house.decay frame has no ownerName at
// all. Coalescing to null here would let every decay transition ERASE a name the
// registry had already resolved.
...(data.ownerName ? { owner_name: String(data.ownerName).slice(0, 120) } : {}),
...decayScheduleFields(data.schedule),
} }
await db.upsertHouse(data.serial, fields) await db.upsertHouse(data.serial, fields)
} }
// Protocol 5's `schedule` object, flattened into its columns.
//
// Unlike ownerName above, these are written back UNCONDITIONALLY, including as nulls.
// A schedule is a claim about the future and it goes stale on its own: if a shard is
// rolled back to a pre-v5 overlay, or a house leaves IDOC so its collapse time stops
// being knowable, the right stored value is "nothing" rather than the last thing we
// were told. A dated promise nobody is maintaining is worse than no promise.
function decayScheduleFields(schedule) {
const s = schedule && typeof schedule === 'object' ? schedule : {}
const when = (v) => {
if (!v) return null
const d = new Date(v)
return Number.isNaN(d.getTime()) ? null : d
}
return {
next_stage: when(s.nextStage),
estimated_collapse: when(s.estimatedCollapse),
decay_period_sec: Number.isFinite(s.decayPeriodSec) ? Math.trunc(s.decayPeriodSec) : null,
dynamic_decay: typeof s.dynamicDecay === 'boolean' ? (s.dynamicDecay ? 1 : 0) : null,
}
}
function shapeHouse(r) { function shapeHouse(r) {
return { return {
serial: r.serial, serial: r.serial,
@@ -149,6 +178,16 @@ function shapeHouse(r) {
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
builtOn: r.built_on, builtOn: r.built_on,
lastRefreshed: r.last_refreshed, lastRefreshed: r.last_refreshed,
// Protocol 5. Re-nested on read for the reason shardMarket re-nests `location`:
// the visibility projection matches literal JSON keys, so the stored read model
// and the live wire frame have to spell this the same way or the one admin rule
// covers only one of the two paths.
schedule: {
dynamicDecay: r.dynamic_decay == null ? null : Boolean(r.dynamic_decay),
nextStage: r.next_stage,
decayPeriodSec: r.decay_period_sec,
estimatedCollapse: r.estimated_collapse,
},
isIdoc: Boolean(r.is_idoc), isIdoc: Boolean(r.is_idoc),
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }

View File

@@ -11,13 +11,16 @@ const { secretBox } = require('../../core')
// Only used before an admin has saved anything — the stored row wins once it exists, // Only used before an admin has saved anything — the stored row wins once it exists,
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar. // and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
// //
// This says 4 because this build handles protocol 4's frames: `guild.roster` and // This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
// `guild.leave` ingest landed with the Teams cutover. It said 3 for a while after // vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
// that, which is the bug this constant is now the fix for — a FRESH install pinned //
// 3, the sidecar answered `409 protocol version mismatch` to every REST call, and a // It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
// new deployment read nothing from its shard until an admin edited the number by // this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
// hand in Admin → Shard. See the matching cutover in db/schema.sql. // `409 protocol version mismatch` to every REST call, and a new deployment read nothing
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 4 // from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
// matching cutover in db/schema.sql.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
function toSafe(row) { function toSafe(row) {
if (!row) { if (!row) {
@@ -92,4 +95,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt
return toSafe(row) return toSafe(row)
} }
module.exports = { getSafe, getWithToken, save, recordStatus } // DEFAULT_PROTOCOL is exported for the schema test, which asserts that this constant
// and schema.sql's two declarations of the same number AGREE, rather than asserting a
// hardcoded version at each site -- which is what let them drift apart before.
module.exports = { getSafe, getWithToken, save, recordStatus, DEFAULT_PROTOCOL }

View File

@@ -144,12 +144,23 @@ test('both settings seeds are INSERT IGNORE, so a replay never resets a value',
// sidecar, which 409s every REST call — an install that reads nothing from its // sidecar, which 409s every REST call — an install that reads nothing from its
// shard, with the cause only in the log. These tests are the guard. // shard, with the cause only in the log. These tests are the guard.
// The protocol this build speaks, read from the model rather than written here.
//
// Hardcoding the number in this test is what the protocol-4 bug looked like from the
// other side: the emitters moved, one declaration site did not, and every site agreed
// with itself. Reading DEFAULT_PROTOCOL makes the assertion "the three declarations
// AGREE" rather than "they all say 4", so a bump that misses one of them fails here
// instead of on an operator's install.
const { DEFAULT_PROTOCOL } = require('../model/uoLinkConfig/uoLinkConfig.model')
test('the column default pins the protocol this build speaks', () => { test('the column default pins the protocol this build speaks', () => {
assert.ok(Number.isInteger(DEFAULT_PROTOCOL) && DEFAULT_PROTOCOL > 0, 'no protocol pin exported')
const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s)) const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
assert.ok(create, 'uo_link_config is gone') assert.ok(create, 'uo_link_config is gone')
assert.match( assert.match(
create, create,
/protocol\s+INT\s+NOT NULL DEFAULT 4/i, new RegExp('protocol +INT +NOT NULL DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
'the CREATE TABLE default must name the protocol this build speaks', 'the CREATE TABLE default must name the protocol this build speaks',
) )
@@ -159,7 +170,34 @@ test('the column default pins the protocol this build speaks', () => {
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s), /^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s),
) )
assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone') assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
assert.match(modifies[modifies.length - 1], /DEFAULT 4/i) assert.match(
modifies[modifies.length - 1],
new RegExp('DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
)
})
// The one-shot migration for the CURRENT protocol, whatever it is. Same argument as
// above: these three assertions used to be written once per version by hand, so the
// version that mattered — the newest — was the one with no test until someone
// remembered to copy the block.
test('the current protocol has a one-shot migration, correctly ordered and guarded', () => {
const marker = `uo_link_protocol_${DEFAULT_PROTOCOL}_migrated`
const update = statements.findIndex(
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes(marker),
)
const insert = statements.findIndex((s) => /^INSERT/i.test(s) && s.includes(`'${marker}'`))
assert.ok(update >= 0, `no migration to protocol ${DEFAULT_PROTOCOL}`)
assert.ok(insert >= 0, `no one-shot marker for protocol ${DEFAULT_PROTOCOL}`)
assert.ok(insert > update, 'the marker is written before the UPDATE reads it')
// `protocol < N`, never `= N-1`: an install that missed an earlier migration has to
// be carried the whole way rather than one step.
assert.match(
statements[update],
new RegExp('protocol *< *' + DEFAULT_PROTOCOL + '(?![0-9])'),
)
}) })
test('the protocol-4 marker is written AFTER the update that reads it', () => { test('the protocol-4 marker is written AFTER the update that reads it', () => {

View File

@@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () =>
const found = await clilocs.resolveMany([1023721]) const found = await clilocs.resolveMany([1023721])
assert.equal(found.get(1023721), 'quarter staff') 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)
}
})

View File

@@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps
assert.equal(typeof out[0].startedAt, 'number') assert.equal(typeof out[0].startedAt, 'number')
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0 assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
}) })
// ── Protocol 5: the decay schedule ─────────────────────────────────────────
test('upsertHouse flattens the nested schedule into its four columns', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'IDOC',
schedule: {
dynamicDecay: true,
nextStage: '2026-09-02T04:00:00.000Z',
decayPeriodSec: 432000,
estimatedCollapse: '2026-09-02T04:00:00.000Z',
},
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.dynamic_decay, 1)
assert.equal(fields.decay_period_sec, 432000)
assert.ok(fields.next_stage instanceof Date)
assert.equal(fields.estimated_collapse.toISOString(), '2026-09-02T04:00:00.000Z')
})
// The whole point of the field: under dynamic decay ServUO draws each stage's
// duration at random on entry, so the shard omits estimatedCollapse everywhere but
// IDOC. A stored null has to mean "not knowable", which it cannot if a partial
// schedule silently keeps the previous value.
test('a schedule without a collapse time stores null, it does not keep the old one', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'Greatly',
schedule: { dynamicDecay: true, nextStage: '2026-09-01T00:00:00.000Z', decayPeriodSec: 432000 },
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.estimated_collapse, null)
assert.ok('estimated_collapse' in fields, 'must be WRITTEN as null, not omitted')
})
// A pre-v5 overlay sends no schedule at all, and a shard can be rolled back to one.
// Every column is still written, so a dismissal date nobody is maintaining cannot
// be left standing.
test('a frame with no schedule nulls all four columns rather than omitting them', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'Fairly' })
const [, fields] = calls.upsertHouse[0]
for (const col of ['next_stage', 'estimated_collapse', 'decay_period_sec', 'dynamic_decay']) {
assert.ok(col in fields, `${col} must be written`)
assert.equal(fields[col], null)
}
})
test('an unparseable schedule date is dropped, not stored as an Invalid Date', async () => {
await shardState.upsertHouse({
serial: 1,
stage: 'IDOC',
schedule: { nextStage: 'soon-ish', estimatedCollapse: '' },
})
const [, fields] = calls.upsertHouse[0]
assert.equal(fields.next_stage, null)
assert.equal(fields.estimated_collapse, null)
})
// house.update writes owner_name from its own sweep. If house.decay coalesced a
// missing ownerName to null, every decay transition on a pre-v5 shard would erase
// a name the registry had already resolved.
test('house.decay never erases an owner_name it was not given', async () => {
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerAcct: 'cadmus' })
const [, fields] = calls.upsertHouse[0]
assert.ok(!('owner_name' in fields), 'owner_name must not be written when absent')
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerName: 'Cadmus' })
const [, withName] = calls.upsertHouse[1]
assert.equal(withName.owner_name, 'Cadmus')
})

View File

@@ -476,3 +476,109 @@ test('a link lookup failure downgrades rather than escalating', async () => {
visibility.forgetUser(6) visibility.forgetUser(6)
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in') assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
}) })
// ── Protocol 5 ─────────────────────────────────────────────────────────────
//
// Two new nested field groups and one new kind. All three exist as visibility
// questions before they exist as features, which is the order this framework's
// rule 2 is designed to force: a v5 field that nobody classified would either
// leak (if it fell open) or be silently invisible (if it fell closed and nobody
// noticed). These tests pin the three answers that were actually chosen.
test('a vendor fee block is admin-only, and it is the whole block', async () => {
const config = await visibility.getConfig()
// The frame as BridgeMarket emits it: the shop's public parts, plus the money.
const frame = {
serial: '0x40001234',
shopName: "Darrow's Bargains",
ownerName: 'Darrow',
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
fees: {
exempt: false,
chargePerPeriod: 148,
funds: 2960,
periodsRemaining: 20,
dismissalAt: '2026-09-20T00:00:00.0000000Z',
},
}
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
const out = visibility.projectFeature('market', frame, level, config)
assert.equal('fees' in out, false, `fees reached ${level}`)
// The rest of the shop is untouched — this is a field rule, not a feature one.
assert.equal(out.shopName, "Darrow's Bargains", `${level} lost the shop name`)
assert.equal(out.location.region, 'Britain', `${level} lost the location`)
}
const asAdmin = visibility.projectFeature('market', frame, 'admin', config)
assert.equal(asAdmin.fees.funds, 2960)
assert.equal(asAdmin.fees.dismissalAt, '2026-09-20T00:00:00.0000000Z')
})
// The nesting is the point, not a style choice: projectValue matches literal JSON
// keys, so seven flat fee keys would be seven rules an admin has to keep in step
// and a v6 field would default to visible. One nested key cannot drift.
test('the fee rule is one nested key, so a new fee field inherits the gate', async () => {
const config = await visibility.getConfig()
const frame = { serial: '0x1', fees: { exempt: false, somethingAddedLater: 'secret' } }
const out = visibility.projectFeature('market', frame, 'staff', config)
assert.equal('fees' in out, false, 'a field added inside fees must not fall out of the gate')
})
// The opposite call, and it is deliberate: the decay countdown is the public IDOC
// page's entire content, and a house at IDOC is already announced in game.
test('the decay schedule is anonymous by default but remains configurable', async () => {
const frame = {
serial: '0x1',
to: 'IDOC',
name: 'Marble Tower',
schedule: {
dynamicDecay: true,
nextStage: '2026-09-02T04:00:00.0000000Z',
decayPeriodSec: 432000,
estimatedCollapse: '2026-09-02T04:00:00.0000000Z',
},
}
const config = await visibility.getConfig()
const anon = visibility.projectFeature('houses', frame, 'anonymous', config)
assert.equal(anon.schedule.estimatedCollapse, '2026-09-02T04:00:00.0000000Z')
// A shard that considers a precise collapse time an unfair advantage can raise it,
// and raising the one nested rule takes the whole schedule with it.
withRows([
{
feature: 'houses',
enabled: true,
audience: 'anonymous',
stream: true,
fieldRules: { schedule: 'staff' },
},
])
const tightened = await visibility.getConfig()
assert.equal('schedule' in visibility.projectFeature('houses', frame, 'player', tightened), false)
assert.equal(
visibility.projectFeature('houses', frame, 'staff', tightened).schedule.decayPeriodSec,
432000,
)
// Tightening the schedule must not have disturbed the owner rules beside it.
assert.equal(visibility.projectFeature('houses', frame, 'anonymous', tightened).name, 'Marble Tower')
})
// Rule 2, exercised on the kind it was added for. account.login.result says whether
// a password was accepted and from which IP; it is admin-only by OMISSION, and the
// omission is the decision. If someone maps it to a feature to "make it visible",
// this fails and says why.
test('account.login.result is admin-only, like the attempt it completes', async () => {
const config = await visibility.getConfig()
assert.equal(
visibility.KIND_FEATURE.has('account.login.result'),
false,
'mapping this kind to a feature would let an admin widen an IP + auth verdict below admin',
)
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
assert.equal(visibility.kindVisibleTo('account.login.result', level, config), false)
}
assert.equal(visibility.kindVisibleTo('account.login.result', 'admin', config), true)
assert.equal(visibility.PUBLIC_KINDS.has('account.login.result'), false)
})

View File

@@ -169,8 +169,14 @@ async function applyStateChange(event, deps) {
name: event.name, name: event.name,
ownerSerial: event.ownerSerial, ownerSerial: event.ownerSerial,
ownerAcct: event.ownerAcct, ownerAcct: event.ownerAcct,
// Protocol 5. `ownerName` used to arrive only on house.update, so a house
// that had decayed but never been swept into the registry named an account
// and no character. It rides house.decay now, which is the frame the IDOC
// page is actually built from.
ownerName: event.ownerName,
builtOn: event.builtOn, builtOn: event.builtOn,
lastRefreshed: event.lastRefreshed, lastRefreshed: event.lastRefreshed,
schedule: event.schedule,
}) })
return return
case 'champ.update': case 'champ.update':

View File

@@ -91,9 +91,22 @@ const FEATURES = {
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the // `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
// REST read models. Both are listed so one rule covers the wire and the read // REST read models. Both are listed so one rule covers the wire and the read
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1. // model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
// Protocol 5 adds `schedule` — when the next stage lands and, where ServUO can
// actually know it, when the house collapses. It defaults to `anonymous` because
// that is what the public IDOC page is FOR: the countdown is the content, and a
// house at IDOC is already announced in game. It is listed rather than left
// unconfigurable so a shard that considers a precise collapse time an unfair
// advantage can raise it, and it is one NESTED key so raising it hides the whole
// schedule rather than three of its four parts.
houses: { houses: {
audience: 'anonymous', audience: 'anonymous',
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' }, fields: {
owner: 'staff',
ownerName: 'staff',
ownerSerial: 'staff',
price: 'staff',
schedule: 'anonymous',
},
}, },
// /public/shard/online listed linked staff to everyone but gated location to // /public/shard/online listed linked staff to everyone but gated location to
// admin+moderator — which is exactly the `staff` rung. // admin+moderator — which is exactly the `staff` rung.
@@ -124,9 +137,25 @@ const FEATURES = {
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses` // `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
// lists both: an admin who hides the owner's name and is left with a serial // lists both: an admin who hides the owner's name and is left with a serial
// that every other board resolves back to that name has not hidden anything. // that every other board resolves back to that name has not hidden anything.
// Protocol 5 adds `fees`, and it does NOT follow the rest of this feature's
// defaults. The shop name, the owner and the location are already visible to any
// player through the stock in-game Vendor Search gump, which is the whole argument
// for publishing them. A vendor's held gold, daily charge and dismissal date are
// not: in game they are visible to the OWNER, on that vendor's own gump. Publishing
// them anonymously would be a genuinely new disclosure and a targeting aid — it
// says which shops are about to be abandoned and how much coin is sitting in each.
// So it defaults to `admin`, the only default here that does not reproduce prior
// behaviour, because there is no prior behaviour to reproduce.
//
// Nested for the same reason `location` is: one rule covers all seven parts.
market: { market: {
audience: 'anonymous', audience: 'anonymous',
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' }, fields: {
ownerName: 'anonymous',
ownerSerial: 'anonymous',
location: 'anonymous',
fees: 'admin',
},
}, },
} }
@@ -177,6 +206,13 @@ const KIND_FEATURE = new Map(
// registry (house.update / house.remove — owner, price, co-owners) stays // registry (house.update / house.remove — owner, price, co-owners) stays
// off the map deliberately, so it remains admin-only exactly as before. // off the map deliberately, so it remains admin-only exactly as before.
'house.decay': 'houses', 'house.decay': 'houses',
// Protocol 5's `account.login.result` is deliberately NOT here, and the omission
// is the decision rather than an oversight. Rule 2 fails an unmapped kind closed
// to admin-only, which is the right answer for a frame that carries an IP address
// and says whether a password was accepted — the same reasoning that keeps
// house.update and account.login.attempt off this map. Adding it would mean
// choosing a feature an admin could then widen, and there is no rung below admin
// this frame belongs on.
// v3 // v3
'world.ruleset': 'ruleset', 'world.ruleset': 'ruleset',
'points.board': 'leaderboards', 'points.board': 'leaderboards',