feat(events): one lease and the participation verbs (Phase 11b)
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user