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:
@@ -205,6 +205,61 @@ const BUDGETS = [
|
||||
},
|
||||
]
|
||||
|
||||
// ── Participation (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ────────────
|
||||
//
|
||||
// EVENTS.md §G rates participation attribution as the largest remaining piece of
|
||||
// new UO work and says why nothing composed out of the existing streams stands in
|
||||
// for it: `region.enter` plus `mob.killed` is loosely composable and NOT
|
||||
// trustworthy enough to publish results on. Nothing scopes a kill or an arrival to
|
||||
// a run, nothing separates a passer-by from an attendee, and nothing survives a
|
||||
// relog.
|
||||
//
|
||||
// So the shard counts, and reports one opaque number per member. Core stores the
|
||||
// number and never interprets it, which is what keeps the engine game-agnostic:
|
||||
// "a minute present plus five a kill" is a sentence about Ultima Online.
|
||||
//
|
||||
// **Members are keyed by character serial**, matching this module's Teams
|
||||
// `memberKey` (`teamProvider.model.js`), so one module speaks one member
|
||||
// vocabulary and a participant joins to a roster without a translation table.
|
||||
|
||||
/** The widest area an event may declare, mirroring the shard's own bound. */
|
||||
const MAX_AREA_RADIUS = 300
|
||||
|
||||
/**
|
||||
* A shard-reported `webId` as a website user id, or undefined.
|
||||
*
|
||||
* The shard writes this only for an account that is actually linked, so most
|
||||
* characters carry none and `undefined` is the ordinary answer rather than a
|
||||
* failure. Checked rather than coerced, because core refuses a `userId` that is
|
||||
* not a positive integer and it is right to: the column is a foreign key into
|
||||
* `users`, and a non-number that happened to survive a coercion would attribute
|
||||
* somebody's attendance to a stranger.
|
||||
*/
|
||||
function webUserId(webId) {
|
||||
if (webId === undefined || webId === null || webId === '') return undefined
|
||||
const n = Number(webId)
|
||||
return Number.isInteger(n) && n > 0 ? n : undefined
|
||||
}
|
||||
|
||||
/** Resolve a `facet/name` landmark to the point the shard counts around. */
|
||||
async function landmarkPoint(value) {
|
||||
const raw = String(value == null ? '' : value)
|
||||
const cut = raw.indexOf('/')
|
||||
if (cut < 1) {
|
||||
return { ok: false, error: `"${raw}" is not a facet/name place` }
|
||||
}
|
||||
|
||||
const facet = raw.slice(0, cut)
|
||||
const name = raw.slice(cut + 1)
|
||||
const rows = await shardAtlas.listLandmarks({ facet })
|
||||
const hit = rows.find((r) => r.facet === facet && r.name === name)
|
||||
|
||||
if (!hit) {
|
||||
return { ok: false, error: `this shard's atlas has no landmark called "${name}" on ${facet}` }
|
||||
}
|
||||
return { ok: true, map: hit.facet, x: hit.x, y: hit.y }
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTIONS = [
|
||||
@@ -484,6 +539,195 @@ const ACTIONS = [
|
||||
|
||||
reconcile: reconcileByBootId,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'uo.participation.open',
|
||||
label: 'Start counting who takes part',
|
||||
description:
|
||||
'Declares where this event happens and starts crediting the players who are there. Presence plus kill credit inside the area, counted on the shard and kept in its world save, so a restart mid-event does not lose the tally.',
|
||||
|
||||
// **`inspect`, not `change`.** Nothing in the world moves and no player can
|
||||
// see it: the shard starts keeping a tally about a place. §K puts the
|
||||
// default-off line between `inspect` and `change`, and a step that only
|
||||
// watches is not one an operator should have to switch on before an event can
|
||||
// record who came.
|
||||
risk: 'inspect',
|
||||
// Ledgered anyway, because the shard IS holding something on this run's
|
||||
// behalf — one of a bounded number of counting slots — and teardown has to
|
||||
// give it back. Reversibility is about what a run owes, not about how loud it
|
||||
// was in taking it.
|
||||
reversible: 'ledger',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'place',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'Felucca/Britain',
|
||||
source: 'uo.options.landmarks',
|
||||
description: 'Where the event happens. The tally counts a circle around this point.',
|
||||
},
|
||||
{
|
||||
name: 'radius',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 40,
|
||||
description: `How many tiles around it count as being there, up to ${MAX_AREA_RADIUS}.`,
|
||||
},
|
||||
{
|
||||
name: 'durationMinutes',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 240,
|
||||
description:
|
||||
'How long to keep counting if nothing closes it. Left out, the shard counts until teardown.',
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const radius = Number(params.radius)
|
||||
if (!Number.isInteger(radius) || radius < 1 || radius > MAX_AREA_RADIUS) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `an area is 1 to ${MAX_AREA_RADIUS} tiles, and "${params.radius}" is not`,
|
||||
}
|
||||
}
|
||||
|
||||
const point = await landmarkPoint(params.place)
|
||||
if (!point.ok) return { ok: false, retry: false, error: point.error }
|
||||
|
||||
let holdMs
|
||||
if (params.durationMinutes !== undefined && params.durationMinutes !== null) {
|
||||
const minutes = Number(params.durationMinutes)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
return { ok: false, retry: false, error: `"${params.durationMinutes}" is not a number of minutes` }
|
||||
}
|
||||
holdMs = Math.round(minutes * 60_000)
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await uoLinkClient.openParticipation({
|
||||
runId,
|
||||
map: point.map,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
radius,
|
||||
holdMs,
|
||||
idempotencyKey,
|
||||
})
|
||||
if (!result.ok) return sidecarFailure(result, 'participation open')
|
||||
|
||||
// **No `bootId` stamp, and that is the point of the phase.** Every other
|
||||
// resource in this file is stamped with the shard boot that made it, because
|
||||
// a town-crier line and a news article live in shard memory and a restart is
|
||||
// definitionally the loss of both. A participation ledger is the first thing
|
||||
// this bridge PERSISTS: it is in the world save, so it survives the restart
|
||||
// that would have proved the others gone. Reconcile has to ask.
|
||||
return {
|
||||
ok: true,
|
||||
resources: [{ kind: 'participation', ref: String(runId), payload: { runId, place: params.place, radius } }],
|
||||
}
|
||||
},
|
||||
|
||||
async revert({ resources }) {
|
||||
const failed = []
|
||||
for (const resource of resources) {
|
||||
const result = await uoLinkClient.closeParticipation({ runId: resource.ref })
|
||||
// §L: "gone, and that is fine" is a successful revert. A run the shard has
|
||||
// already forgotten answers `known: false` with a 200 for exactly this.
|
||||
if (!result.ok && result.status !== 404) failed.push(resource.ref)
|
||||
}
|
||||
if (!failed.length) return { ok: true }
|
||||
return { ok: true, failed }
|
||||
},
|
||||
|
||||
/**
|
||||
* **Not `reconcileByBootId`, and this is the one resource for which that is
|
||||
* true.** The boot-stamp trick works because a crier line and a news article
|
||||
* live in shard memory, so a changed `bootId` IS the proof they are gone. A
|
||||
* participation ledger is written into the world save specifically so that it
|
||||
* survives a restart, and reporting it lost on a boot change would orphan the
|
||||
* one resource the phase went to the trouble of persisting.
|
||||
*
|
||||
* So it asks. A 404 is the shard saying it is not counting that run; anything
|
||||
* else unanswerable leaves the row alone.
|
||||
*/
|
||||
async reconcile({ resources }) {
|
||||
const inForce = []
|
||||
for (const resource of resources) {
|
||||
const result = await uoLinkClient.snapshotParticipation({ runId: resource.ref })
|
||||
if (result.ok) {
|
||||
inForce.push(resource.ref)
|
||||
continue
|
||||
}
|
||||
// Only an explicit "I am not counting that" takes a row out. A shard that
|
||||
// is down, slow or refusing has not said the ledger is gone.
|
||||
if (result.status !== 404) inForce.push(resource.ref)
|
||||
}
|
||||
return { ok: true, inForce }
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'uo.participation.collect',
|
||||
label: 'Record who took part',
|
||||
description:
|
||||
"Reads the shard's tally for this run and files it as the run's participants, so results and player history have something true to render.",
|
||||
|
||||
risk: 'inspect',
|
||||
// Nothing is created and nothing is owed. The rows it writes are core's
|
||||
// `event_run_participants`, whose `UNIQUE (run_id, member_key)` makes a
|
||||
// retried collect an upsert rather than a doubled leaderboard.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
|
||||
params: [],
|
||||
|
||||
async perform({ runId, idempotencyKey, verify }) {
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await uoLinkClient.snapshotParticipation({ runId, idempotencyKey })
|
||||
if (!result.ok) {
|
||||
// 425 is `bridge.busy`: a snapshot of this run is already walking on the
|
||||
// shard. Transient by construction — the work is happening — and it is not
|
||||
// in `PERMANENT_STATUSES`, so `sidecarFailure` classifies it retry without
|
||||
// needing an arm of its own.
|
||||
return sidecarFailure(result, 'participation tally')
|
||||
}
|
||||
|
||||
const rows = (result.data && result.data.participants) || []
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
participants: rows.map((row) => ({
|
||||
// The serial, which is this module's member vocabulary everywhere.
|
||||
memberKey: row.serial,
|
||||
// **Resolved here, and only when the shard could resolve it.** A
|
||||
// `userId` is a foreign key into `users`, and core refuses anything that
|
||||
// is not a positive integer rather than coercing — a character serial
|
||||
// passed here would either fail the insert or, worse, attribute
|
||||
// somebody's attendance to a stranger who happened to hold that id.
|
||||
userId: webUserId(row.webId),
|
||||
score: row.score,
|
||||
joinedAt: row.firstMs ? new Date(row.firstMs) : undefined,
|
||||
// Opaque to core, and carried so a results table can say WHY somebody
|
||||
// scored what they did. A number an operator can only believe or not is
|
||||
// a number they will not defend when a player argues with it.
|
||||
meta: {
|
||||
name: row.name || null,
|
||||
seconds: row.seconds,
|
||||
minutes: row.minutes,
|
||||
kills: row.kills,
|
||||
},
|
||||
})),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -512,6 +756,119 @@ async function reconcileByBootId({ resources }) {
|
||||
return { ok: true, inForce }
|
||||
}
|
||||
|
||||
// ── Leases (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ───────────────────
|
||||
//
|
||||
// **One key, and the catalog is short because ServUO made it short.** EVENTS.md
|
||||
// §D describes the 258 `Config.Get` call sites as splitting into two patterns —
|
||||
// cached at type initialisation, where a lease applies cleanly and does nothing,
|
||||
// and read live, where it takes effect at once. Measured on 57.4 the split is not
|
||||
// near even: of the 158 non-Bridge sites in `Scripts/`, roughly eight are live
|
||||
// reads. Phase 11b ships the one that is both live and observable, and Phase 12
|
||||
// adds the rest behind the boot-time self-check that drops a key which does not
|
||||
// take.
|
||||
//
|
||||
// The module never writes a lease and never bounds one. An author puts
|
||||
// `core.lease` in a step; core reads the baseline, reserves the target against
|
||||
// the two-events-one-target index, applies the value with its deadline and
|
||||
// restores it at teardown through `restore()` below. What is here is the three
|
||||
// callables, plus the fourth this phase added.
|
||||
|
||||
/** How long core will let this deployment hold a config lease. Twelve hours. */
|
||||
const MAX_LEASE_MS = 12 * 60 * 60 * 1000
|
||||
|
||||
/** The shard's lease list, or null when it could not be read. */
|
||||
async function leaseRow(key) {
|
||||
const result = await uoLinkClient.getLeases()
|
||||
if (!result.ok) return null
|
||||
const rows = (result.data && result.data.leases) || []
|
||||
return rows.find((r) => r && r.key === key) || null
|
||||
}
|
||||
|
||||
const LEASES = [
|
||||
{
|
||||
id: 'uo.playercaps.skillcap',
|
||||
label: 'Starting skill cap',
|
||||
description:
|
||||
"The per-skill cap a newly created character starts with. Read live at character creation, so it applies to everyone made while the lease is held and to nobody made before it.",
|
||||
type: 'float',
|
||||
// The shard enforces the same bounds independently, and that duplication is
|
||||
// deliberate: this pair is what core checks at AUTHORING time so a bad value
|
||||
// is a refusal on a form, and the shard's pair is what is true when the
|
||||
// website is wrong.
|
||||
min: 1000,
|
||||
max: 1500,
|
||||
maxDurationMs: MAX_LEASE_MS,
|
||||
|
||||
async read() {
|
||||
const row = await leaseRow('PlayerCaps.SkillCap')
|
||||
if (!row) return { ok: false, error: 'the shard did not report its lease catalog' }
|
||||
return { ok: true, value: row.current }
|
||||
},
|
||||
|
||||
async apply(value, until) {
|
||||
// **A duration, not the deadline.** `until` is an absolute time computed
|
||||
// here and honoured there, which is a deadline measured against two clocks;
|
||||
// a shard running ten minutes fast would restore a ten-minute lease the
|
||||
// instant it took it. The absolute time still rides along, because a
|
||||
// console that can say when the hold ends is worth the extra field.
|
||||
const holdMs = new Date(until).getTime() - Date.now()
|
||||
if (!Number.isFinite(holdMs) || holdMs <= 0) {
|
||||
return { ok: false, error: 'the lease deadline has already passed' }
|
||||
}
|
||||
|
||||
const result = await uoLinkClient.applyLease({
|
||||
key: 'PlayerCaps.SkillCap',
|
||||
value,
|
||||
holdMs: Math.round(holdMs),
|
||||
untilMs: new Date(until).getTime(),
|
||||
})
|
||||
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease') }
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
async restore(baseline, { expected } = {}) {
|
||||
const result = await uoLinkClient.releaseLease({
|
||||
key: 'PlayerCaps.SkillCap',
|
||||
expected,
|
||||
baseline,
|
||||
})
|
||||
|
||||
// **Drift is a 200 carrying `lease.drifted`, not an HTTP failure**, because
|
||||
// the shard did exactly what it was asked: it compared, and it declined to
|
||||
// overwrite somebody's deliberate change. Core records that as a distinct
|
||||
// successful outcome rather than an error, so the shape it wants back is
|
||||
// `{ ok: false, drifted: true, current }` and not a thrown call.
|
||||
if (result.ok && result.data && result.data.kind === 'lease.drifted') {
|
||||
return { ok: false, drifted: true, current: result.data.current }
|
||||
}
|
||||
|
||||
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease release') }
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the shard still has a record of the hold (Phase 11b).
|
||||
*
|
||||
* **Not a comparison against `read()`**, and the difference is the whole
|
||||
* reason this callable exists. A value that differs from what the run applied
|
||||
* is DRIFT, which `restore()` above reports so the ledger row lands `drifted`
|
||||
* with the current value beside it; answering "not in force" here would orphan
|
||||
* the row first and tell the operator the lease vanished rather than that
|
||||
* somebody moved it.
|
||||
*
|
||||
* A config lease is memory-only on the shard, so a restart reverts it and the
|
||||
* catalog reports `held: false` — which is exactly the case core could not see
|
||||
* before this phase, and the reason a restarted shard used to leave a run
|
||||
* hunting a baseline nobody was holding.
|
||||
*/
|
||||
async inForce() {
|
||||
const row = await leaseRow('PlayerCaps.SkillCap')
|
||||
if (!row) return { ok: false, error: 'the shard did not report its lease catalog' }
|
||||
return { ok: true, held: row.held === true }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Option sources ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Answered from the spawn atlas, which is derived from the operator's own ServUO
|
||||
@@ -586,6 +943,7 @@ const OPTION_SOURCES = [
|
||||
module.exports = {
|
||||
ACTIONS,
|
||||
BUDGETS,
|
||||
LEASES,
|
||||
OPTION_SOURCES,
|
||||
// Exported for the tests, which assert the caps and the classification rules
|
||||
// against the same constants the declarations use rather than against literals
|
||||
@@ -597,7 +955,11 @@ module.exports = {
|
||||
MAX_NEWS_TITLE,
|
||||
MAX_NEWS_BODY,
|
||||
MAX_OPTIONS,
|
||||
MAX_AREA_RADIUS,
|
||||
MAX_LEASE_MS,
|
||||
PERMANENT_STATUSES,
|
||||
webUserId,
|
||||
landmarkPoint,
|
||||
sidecarReason,
|
||||
resourceId,
|
||||
crierLines,
|
||||
|
||||
Reference in New Issue
Block a user