feat(events): one lease and the participation verbs (Phase 11b)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 26s
PR Checks / frozen-manifest (pull_request) Successful in 40s

The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.

`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.

Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.

`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:31:57 -05:00
parent bf9a702cfa
commit 88bfe9310e
8 changed files with 846 additions and 1 deletions

View File

@@ -246,6 +246,14 @@ const KIND_FEATURE = new Map(
// needs it live. An admin can turn it on.
'vendor.listing': 'market',
'vendor.listing.remove': 'market',
// Protocol 6 part b's `lease.applied` and `lease.expired` are deliberately NOT
// here, on the same reasoning that keeps `account.login.result` off it. They are
// operational frames about the WEBSITE changing this shard's configuration --
// which key, from what to what, on whose run, and whether the shard's own
// deadline had to put it back because nobody asked. Rule 2 fails an unmapped
// kind closed to admin-only, which is where an audit trail of the site's writes
// belongs; mapping them would mean choosing a feature an operator could then
// widen, and there is no rung below admin these frames belong on.
}),
)

View File

@@ -223,6 +223,72 @@ const adminUnban = ({ actor, account }) =>
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
//
// Leases and the run-scoped participation ledger. Both are gated on the shard by
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
// operator consenting to staff moderation from a screen has not thereby consented
// to the website changing their world on a schedule at four in the morning. A
// shard with the plane off answers 403, and the actions turn that into a refusal
// an author can read rather than a retry.
// Every lease this shard offers, with what each is worth right now and what is
// holding it. One read serves both questions core asks — `read()` wants the
// current value, `inForce()` wants to know whether the shard still has a record
// of the hold — so a lease costs one round trip, not two.
const getLeases = () => call('/lease')
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
// computed here and honoured there is a deadline measured against two clocks, and
// a shard running ten minutes fast would restore a ten-minute lease the moment it
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
const applyLease = ({ key, value, holdMs, untilMs, runId, idempotencyKey }) =>
call('/lease', {
method: 'POST',
body: { key, value: String(value), holdMs, untilMs, runId, idempotencyKey },
})
// `expected` is what this run applied and `baseline` is what to put back, both out
// of core's ledger rather than the shard's memory — so a release still works after
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
// reverts every config lease by design) answers honestly instead of refusing.
const releaseLease = ({ key, expected, baseline, idempotencyKey }) =>
call('/lease/release', {
method: 'POST',
body: {
key,
expected: expected == null ? undefined : String(expected),
baseline: baseline == null ? undefined : String(baseline),
idempotencyKey,
},
})
// The participation ledger. The area is a map, a point and a radius rather than a
// region name, because protocol 6's own walk established that the most specific
// region containing an event is routinely anonymous.
const openParticipation = ({ runId, map, x, y, radius, holdMs, idempotencyKey }) =>
call('/participation', {
method: 'POST',
body: { runId: String(runId), map, x, y, radius, holdMs, idempotencyKey },
})
// A POST for a read, and the reason is the phase's headline: on a well-attended
// run the shard walks its members across Core ticks rather than in one inbound
// call, so a repeat arriving mid-walk is answered `bridge.busy` (425). A read that
// can legitimately be refused as a repeat in flight is not a GET.
const snapshotParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/snapshot`, {
method: 'POST',
body: { idempotencyKey },
})
const closeParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/close`, {
method: 'POST',
body: { idempotencyKey },
})
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
@@ -256,6 +322,12 @@ module.exports = {
deleteTownCrier,
postNews,
deleteNews,
getLeases,
applyLease,
releaseLease,
openParticipation,
snapshotParticipation,
closeParticipation,
adminKick,
adminBan,
adminUnban,