diff --git a/module.json b/module.json index 26610c2..d859dbb 100644 --- a/module.json +++ b/module.json @@ -1,7 +1,7 @@ { "id": "uo", "name": "Ultima Online", - "version": "0.3.0", + "version": "0.4.0", "coreApi": "^1.3.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, diff --git a/server/db/schema.sql b/server/db/schema.sql index b370e20..e86ca58 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config ( base_url VARCHAR(255) NULL, ws_url VARCHAR(255) 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, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', 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_name VARCHAR(64) NULL; 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'); diff --git a/server/model/shardMarket/shardMarket.db.js b/server/model/shardMarket/shardMarket.db.js index 7e33578..e631852 100644 --- a/server/model/shardMarket/shardMarket.db.js +++ b/server/model/shardMarket/shardMarket.db.js @@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) { await conn.query( `INSERT INTO shard_vendors - (serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house, - item_count, item_total, truncated, t) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + (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), 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), 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 @@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) { 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, @@ -70,6 +82,13 @@ async function replaceVendor(vendor, items) { 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, ], ) diff --git a/server/model/shardMarket/shardMarket.model.js b/server/model/shardMarket/shardMarket.model.js index 423fad4..9e2d818 100644 --- a/server/model/shardMarket/shardMarket.model.js +++ b/server/model/shardMarket/shardMarket.model.js @@ -31,6 +31,7 @@ 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 @@ -43,6 +44,42 @@ const int = (value, fallback = 0) => { 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 ───────────────────────────────────────────────────────────────── /** @@ -64,6 +101,10 @@ function flattenFrame(ev) { 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, @@ -76,6 +117,7 @@ function flattenFrame(ev) { itemTotal: int(ev.total, int(ev.count, 0)), truncated: ev.truncated === true, t: Number.isFinite(ev.t) ? ev.t : null, + ...fees(ev.fees), } } diff --git a/server/model/shardState/shardState.db.js b/server/model/shardState/shardState.db.js index 4dbc569..9f16061 100644 --- a/server/model/shardState/shardState.db.js +++ b/server/model/shardState/shardState.db.js @@ -98,7 +98,12 @@ async function latestEconomy() { // ── Houses / IDOC ──────────────────────────────────────────────────────── 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) diff --git a/server/model/shardState/shardState.model.js b/server/model/shardState/shardState.model.js index 7c25254..d4d48c3 100644 --- a/server/model/shardState/shardState.model.js +++ b/server/model/shardState/shardState.model.js @@ -124,10 +124,39 @@ async function upsertHouse(data) { built_on: data.builtOn ? new Date(data.builtOn) : null, last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, 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) } +// 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) { return { serial: r.serial, @@ -149,6 +178,16 @@ function shapeHouse(r) { inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), builtOn: r.built_on, 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), updatedAt: r.updated_at, } diff --git a/server/model/uoLinkConfig/uoLinkConfig.model.js b/server/model/uoLinkConfig/uoLinkConfig.model.js index 80ae4f3..fe45ddd 100644 --- a/server/model/uoLinkConfig/uoLinkConfig.model.js +++ b/server/model/uoLinkConfig/uoLinkConfig.model.js @@ -11,13 +11,16 @@ const { secretBox } = require('../../core') // 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. // -// This says 4 because this build handles protocol 4's frames: `guild.roster` and -// `guild.leave` ingest landed with the Teams cutover. It said 3 for a while after -// 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 -// new deployment read nothing from its shard until an admin edited the number by -// hand in Admin → Shard. See the matching cutover in db/schema.sql. -const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 4 +// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`, +// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind. +// +// It said 4 before that, and 3 for a while after protocol 4 shipped — 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 new deployment read nothing +// 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) { if (!row) { @@ -92,4 +95,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt 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 } diff --git a/server/test/schemaFragment.test.js b/server/test/schemaFragment.test.js index 57352b4..53bd9b4 100644 --- a/server/test/schemaFragment.test.js +++ b/server/test/schemaFragment.test.js @@ -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 // 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', () => { + 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)) assert.ok(create, 'uo_link_config is gone') assert.match( 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', ) @@ -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), ) 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', () => { diff --git a/server/test/shardMarket.model.test.js b/server/test/shardMarket.model.test.js index 70c18c9..702337b 100644 --- a/server/test/shardMarket.model.test.js +++ b/server/test/shardMarket.model.test.js @@ -261,3 +261,91 @@ 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) + } +}) diff --git a/server/test/shardState.model.test.js b/server/test/shardState.model.test.js index 1b439e7..8428289 100644 --- a/server/test/shardState.model.test.js +++ b/server/test/shardState.model.test.js @@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps assert.equal(typeof out[0].startedAt, 'number') 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') +}) diff --git a/server/test/shardVisibility.test.js b/server/test/shardVisibility.test.js index 3e53c80..771e874 100644 --- a/server/test/shardVisibility.test.js +++ b/server/test/shardVisibility.test.js @@ -476,3 +476,109 @@ test('a link lookup failure downgrades rather than escalating', async () => { visibility.forgetUser(6) 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) +}) diff --git a/server/utils/shardIngest.js b/server/utils/shardIngest.js index b21f6f1..62ad8a1 100644 --- a/server/utils/shardIngest.js +++ b/server/utils/shardIngest.js @@ -169,8 +169,14 @@ async function applyStateChange(event, deps) { name: event.name, ownerSerial: event.ownerSerial, 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, lastRefreshed: event.lastRefreshed, + schedule: event.schedule, }) return case 'champ.update': diff --git a/server/utils/shardVisibility.js b/server/utils/shardVisibility.js index a098569..8f50510 100644 --- a/server/utils/shardVisibility.js +++ b/server/utils/shardVisibility.js @@ -91,9 +91,22 @@ const FEATURES = { // `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 // 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: { 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 // admin+moderator — which is exactly the `staff` rung. @@ -124,9 +137,25 @@ const FEATURES = { // `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 // 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: { 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 // off the map deliberately, so it remains admin-only exactly as before. '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 'world.ruleset': 'ruleset', 'points.board': 'leaderboards',