feat(shard): ingest protocol 5 — decay schedule, vendor fees, login result
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / frozen-manifest (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Successful in 8m46s

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>
This commit is contained in:
2026-08-31 19:20:15 -05:00
parent 1b6d92a5ba
commit 6a276a7ec3
13 changed files with 534 additions and 19 deletions

View File

@@ -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)
})