feat(events): one lease and the participation verbs (Phase 11b) #30
@@ -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 ────────────────────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ACTIONS = [
|
const ACTIONS = [
|
||||||
@@ -484,6 +539,195 @@ const ACTIONS = [
|
|||||||
|
|
||||||
reconcile: reconcileByBootId,
|
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 }
|
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 ─────────────────────────────────────────────────────────
|
// ── Option sources ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Answered from the spawn atlas, which is derived from the operator's own ServUO
|
// Answered from the spawn atlas, which is derived from the operator's own ServUO
|
||||||
@@ -586,6 +943,7 @@ const OPTION_SOURCES = [
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
ACTIONS,
|
ACTIONS,
|
||||||
BUDGETS,
|
BUDGETS,
|
||||||
|
LEASES,
|
||||||
OPTION_SOURCES,
|
OPTION_SOURCES,
|
||||||
// Exported for the tests, which assert the caps and the classification rules
|
// Exported for the tests, which assert the caps and the classification rules
|
||||||
// against the same constants the declarations use rather than against literals
|
// against the same constants the declarations use rather than against literals
|
||||||
@@ -597,7 +955,11 @@ module.exports = {
|
|||||||
MAX_NEWS_TITLE,
|
MAX_NEWS_TITLE,
|
||||||
MAX_NEWS_BODY,
|
MAX_NEWS_BODY,
|
||||||
MAX_OPTIONS,
|
MAX_OPTIONS,
|
||||||
|
MAX_AREA_RADIUS,
|
||||||
|
MAX_LEASE_MS,
|
||||||
PERMANENT_STATUSES,
|
PERMANENT_STATUSES,
|
||||||
|
webUserId,
|
||||||
|
landmarkPoint,
|
||||||
sidecarReason,
|
sidecarReason,
|
||||||
resourceId,
|
resourceId,
|
||||||
crierLines,
|
crierLines,
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ const engagementSeeds = require('./config/engagementSeeds')
|
|||||||
// module is willing to make unattended.
|
// module is willing to make unattended.
|
||||||
api.registerEventBudgets(uoEventActions.BUDGETS)
|
api.registerEventBudgets(uoEventActions.BUDGETS)
|
||||||
api.registerEventActions(uoEventActions.ACTIONS)
|
api.registerEventActions(uoEventActions.ACTIONS)
|
||||||
|
// Phase 11b. One live-read config key, and the module never writes it: an author
|
||||||
|
// puts `core.lease` in a step and core owns the duration bound, the
|
||||||
|
// two-events-one-target check and the teardown restore.
|
||||||
|
api.registerEventLeases(uoEventActions.LEASES)
|
||||||
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
|
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
|
||||||
|
|
||||||
api.onBoot(boot.onBoot)
|
api.onBoot(boot.onBoot)
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ function fakeApi() {
|
|||||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||||
|
// And the fourth, from Phase 11b. `once` for the same reason, and present here
|
||||||
|
// for a second one: a verb this module calls and this fake does not have is a
|
||||||
|
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
|
||||||
|
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,19 @@ test('registers exactly what module.json declares', () => {
|
|||||||
// anywhere.
|
// anywhere.
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
api.record.eventActions.map((a) => a.id).sort(),
|
api.record.eventActions.map((a) => a.id).sort(),
|
||||||
['uo.broadcast', 'uo.news.post', 'uo.towncrier.post'],
|
[
|
||||||
|
'uo.broadcast',
|
||||||
|
'uo.news.post',
|
||||||
|
'uo.participation.collect',
|
||||||
|
'uo.participation.open',
|
||||||
|
'uo.towncrier.post',
|
||||||
|
],
|
||||||
)
|
)
|
||||||
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts'])
|
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts'])
|
||||||
|
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
||||||
|
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
||||||
|
// on any of the rest applies cleanly and does nothing.
|
||||||
|
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||||
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
|
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ beforeEach(() => {
|
|||||||
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
||||||
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
||||||
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
||||||
|
// Phase 11b. `uo.participation.open` resolves its `place` param against the
|
||||||
|
// atlas, so the dry-run sweep below reaches this rather than the database.
|
||||||
|
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
382
server/test/uoEventLeaseParticipation.test.js
Normal file
382
server/test/uoEventLeaseParticipation.test.js
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// module-uo's half of protocol 6 part b (EVENTS_PLAN.md Phase 11b).
|
||||||
|
//
|
||||||
|
// One lease and two participation verbs. What is worth asserting here is not that
|
||||||
|
// the calls happen — a rig proves that better — but the handful of places where
|
||||||
|
// the obvious implementation is subtly the wrong one, and where nothing would fail
|
||||||
|
// if it were written the other way:
|
||||||
|
//
|
||||||
|
// • a lease's `restore()` must turn `lease.drifted` into `{ drifted: true }`
|
||||||
|
// rather than an error, because core records drift as a distinct SUCCESSFUL
|
||||||
|
// outcome and an error would put the row on the retry ladder instead
|
||||||
|
// • `inForce()` must not be a comparison against `read()` — a changed value is
|
||||||
|
// drift, which teardown reports, and orphaning the row first destroys it
|
||||||
|
// • `apply()` must send a DURATION, not the deadline, or a shard whose clock is
|
||||||
|
// fast restores the lease the instant it takes it
|
||||||
|
// • `uo.participation.open` must NOT reconcile by boot stamp, which every other
|
||||||
|
// resource in this module does — the ledger is persisted in the world save
|
||||||
|
// precisely so that it survives the restart the stamp would report it lost by
|
||||||
|
// • a `userId` is a foreign key and a character serial is not, so an unresolved
|
||||||
|
// one is undefined rather than coerced
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
require('./_setup')
|
||||||
|
const actions = require('../config/uoEventActions')
|
||||||
|
|
||||||
|
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||||
|
const lease = () => actions.LEASES.find((l) => l.id === 'uo.playercaps.skillcap')
|
||||||
|
|
||||||
|
const STUBBED = [
|
||||||
|
'getLeases',
|
||||||
|
'applyLease',
|
||||||
|
'releaseLease',
|
||||||
|
'openParticipation',
|
||||||
|
'snapshotParticipation',
|
||||||
|
'closeParticipation',
|
||||||
|
]
|
||||||
|
|
||||||
|
let calls
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = { apply: [], release: [], open: [], snapshot: [], close: [] }
|
||||||
|
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||||
|
saved.listLandmarks = shardAtlas.listLandmarks
|
||||||
|
|
||||||
|
uoLinkClient.getLeases = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||||
|
})
|
||||||
|
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.openParticipation = async (b) => { calls.open.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.snapshotParticipation = async (b) => {
|
||||||
|
calls.snapshot.push(b)
|
||||||
|
return { ok: true, status: 200, data: { participants: [] } }
|
||||||
|
}
|
||||||
|
uoLinkClient.closeParticipation = async (b) => { calls.close.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
|
||||||
|
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||||
|
shardAtlas.listLandmarks = saved.listLandmarks
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The lease ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the lease satisfies the shape core validates it with', () => {
|
||||||
|
const l = lease()
|
||||||
|
assert.ok(l.id.startsWith('uo.'), 'a lease is namespaced to its module')
|
||||||
|
assert.ok(l.label && l.description)
|
||||||
|
assert.equal(l.type, 'float')
|
||||||
|
// Required for the numeric types, and unlike a cap a bad lease value is in
|
||||||
|
// force the moment it is applied.
|
||||||
|
assert.ok(Number.isFinite(l.min) && Number.isFinite(l.max) && l.min < l.max)
|
||||||
|
assert.ok(Number.isInteger(l.maxDurationMs) && l.maxDurationMs > 0)
|
||||||
|
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||||||
|
assert.equal(typeof l[fn], 'function', `a lease needs ${fn}()`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('apply sends a DURATION, because a deadline is measured against two clocks', async () => {
|
||||||
|
const until = new Date(Date.now() + 90 * 60_000)
|
||||||
|
const answer = await lease().apply(1200, until)
|
||||||
|
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
const sent = calls.apply[0]
|
||||||
|
// The number the shard arms its timer off. Computed here from the deadline, so
|
||||||
|
// a shard running ten minutes fast holds the lease for ninety minutes of its
|
||||||
|
// own time rather than restoring it the instant it takes it.
|
||||||
|
assert.ok(Math.abs(sent.holdMs - 90 * 60_000) < 2000, `holdMs was ${sent.holdMs}`)
|
||||||
|
// And the absolute time still rides along, for a console that wants to say when
|
||||||
|
// the hold ends in terms the operator's own clock agrees with.
|
||||||
|
assert.equal(sent.untilMs, until.getTime())
|
||||||
|
// The action hands the value on unchanged; `uoLinkClient.applyLease` is what
|
||||||
|
// renders it as TEXT, which is the wire's contract for every lease type: `1200`
|
||||||
|
// and `1200.0` are one number to a JSON parser and two different strings to a
|
||||||
|
// compare-and-set.
|
||||||
|
assert.equal(sent.value, 1200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a deadline that has already passed is refused rather than sent as a negative hold', async () => {
|
||||||
|
const answer = await lease().apply(1200, new Date(Date.now() - 60_000))
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.match(answer.error, /already passed/)
|
||||||
|
assert.equal(calls.apply.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drift comes back as drifted, not as an error', async () => {
|
||||||
|
// The distinction core acts on. `cleanup.js` records `drifted` as its own
|
||||||
|
// outcome — the module did exactly what it was asked and found somebody else's
|
||||||
|
// value in place — while an error would put the row on the retry ladder and
|
||||||
|
// eventually spend its attempts on a situation only a human can resolve.
|
||||||
|
uoLinkClient.releaseLease = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { kind: 'lease.drifted', key: 'PlayerCaps.SkillCap', current: '1300' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const answer = await lease().restore('1000', { expected: '1200' })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.drifted, true)
|
||||||
|
assert.equal(answer.current, '1300')
|
||||||
|
assert.equal(answer.error, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restore sends both what it applied and what to put back', async () => {
|
||||||
|
await lease().restore('1000', { expected: '1200' })
|
||||||
|
// Core's `restore(baseline, { expected })` carries no key of its own -- teardown
|
||||||
|
// is core's own sweep rather than a step dispatch -- so neither does this.
|
||||||
|
assert.deepEqual(calls.release[0], {
|
||||||
|
key: 'PlayerCaps.SkillCap',
|
||||||
|
expected: '1200',
|
||||||
|
baseline: '1000',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inForce asks whether the shard still HOLDS it, not whether the value still matches', async () => {
|
||||||
|
// The reason this callable exists at all. A shard reporting a value that is not
|
||||||
|
// what the run applied is reporting DRIFT, which teardown delivers through
|
||||||
|
// `restore()` 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.
|
||||||
|
uoLinkClient.getLeases = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1300', held: true }] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(await lease().inForce(), { ok: true, held: true })
|
||||||
|
|
||||||
|
// And a shard that restarted: a config lease is memory-only there by design, so
|
||||||
|
// the value is back at baseline AND the record is gone. This is the case core
|
||||||
|
// could not see before this phase.
|
||||||
|
uoLinkClient.getLeases = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(await lease().inForce(), { ok: true, held: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a shard that cannot answer leaves the ledger alone', async () => {
|
||||||
|
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, error: 'shard not connected' })
|
||||||
|
const answer = await lease().inForce()
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
// `ok: false` is what core reads as "I could not ask", and it keeps believing
|
||||||
|
// its own ledger. Never `held: false`, which would orphan a live lease the
|
||||||
|
// first time a sidecar was slow.
|
||||||
|
assert.equal(answer.held, undefined)
|
||||||
|
assert.equal((await lease().read()).ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Participation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('open resolves a named place to the point the shard counts around', async () => {
|
||||||
|
const answer = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius: 40, durationMinutes: 120 },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
assert.deepEqual(calls.open[0], {
|
||||||
|
runId: 42,
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1496,
|
||||||
|
y: 1628,
|
||||||
|
radius: 40,
|
||||||
|
holdMs: 7_200_000,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
})
|
||||||
|
assert.deepEqual(answer.resources, [
|
||||||
|
{ kind: 'participation', ref: '42', payload: { runId: 42, place: 'Felucca/Britain', radius: 40 } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a place the atlas does not know is a refusal an author can read, not a retry', async () => {
|
||||||
|
const answer = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||||
|
})
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.retry, false)
|
||||||
|
assert.match(answer.error, /no landmark called "Atlantis"/)
|
||||||
|
assert.equal(calls.open.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an area outside the bound is refused before anything is sent', async () => {
|
||||||
|
for (const radius of [0, -1, actions.MAX_AREA_RADIUS + 1, 1.5]) {
|
||||||
|
const answer = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius },
|
||||||
|
})
|
||||||
|
assert.equal(answer.ok, false, String(radius))
|
||||||
|
assert.equal(answer.retry, false, String(radius))
|
||||||
|
}
|
||||||
|
assert.equal(calls.open.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run checks the place and the radius and opens nothing', async () => {
|
||||||
|
const good = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius: 40 },
|
||||||
|
verify: true,
|
||||||
|
})
|
||||||
|
assert.deepEqual(good, { ok: true })
|
||||||
|
assert.equal(calls.open.length, 0)
|
||||||
|
|
||||||
|
// And it is a real check rather than an unconditional yes: the failure an
|
||||||
|
// author most wants caught before the night of the event is a place that is not
|
||||||
|
// on this shard's map.
|
||||||
|
const bad = await byId('uo.participation.open').perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||||
|
verify: true,
|
||||||
|
})
|
||||||
|
assert.equal(bad.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the ledger is NOT reconciled by boot stamp, unlike everything else here', async () => {
|
||||||
|
// The phase's one genuine divergence from wave 1. `reconcileByBootId` 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 — reporting it lost
|
||||||
|
// on a boot change would orphan the one resource the phase persisted.
|
||||||
|
const open = byId('uo.participation.open')
|
||||||
|
assert.notEqual(open.reconcile, actions.reconcileByBootId)
|
||||||
|
// No stamp on the resource either, so nothing downstream can be tempted to
|
||||||
|
// compare one.
|
||||||
|
const answer = await open.perform({
|
||||||
|
runId: 42,
|
||||||
|
idempotencyKey: 'k-1',
|
||||||
|
params: { place: 'Felucca/Britain', radius: 40 },
|
||||||
|
})
|
||||||
|
assert.equal(answer.resources[0].payload.bootId, undefined)
|
||||||
|
|
||||||
|
// It asks instead, and only an explicit 404 takes a row out.
|
||||||
|
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||||
|
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||||
|
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: [] })
|
||||||
|
|
||||||
|
// A shard that is down has not said the ledger is gone.
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||||
|
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a run the shard has already forgotten is a successful revert', async () => {
|
||||||
|
// §L: "gone, and that is fine". A shard that restarted past its grace window,
|
||||||
|
// or a second teardown attempt, must not leave a row failing forever.
|
||||||
|
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||||
|
assert.deepEqual(await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true })
|
||||||
|
|
||||||
|
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||||
|
assert.deepEqual(
|
||||||
|
await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }),
|
||||||
|
{ ok: true, failed: ['42'] },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collect files the tally as participants, keyed by character serial', async () => {
|
||||||
|
uoLinkClient.snapshotParticipation = async (b) => {
|
||||||
|
calls.snapshot.push(b)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
serial: '0x400150E8',
|
||||||
|
name: 'Darrow',
|
||||||
|
acct: 'seed_001',
|
||||||
|
webId: '17',
|
||||||
|
seconds: 3600,
|
||||||
|
minutes: '60.00',
|
||||||
|
kills: 3,
|
||||||
|
score: '75.0000',
|
||||||
|
firstMs: 1788550182074,
|
||||||
|
},
|
||||||
|
// No account link: the shard reports no webId, and there is nothing to
|
||||||
|
// resolve. Most characters are this one.
|
||||||
|
{
|
||||||
|
serial: '0x1',
|
||||||
|
name: 'Nobody',
|
||||||
|
seconds: 60,
|
||||||
|
minutes: '1.00',
|
||||||
|
kills: 0,
|
||||||
|
score: '1.0000',
|
||||||
|
firstMs: 1788550182074,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||||
|
|
||||||
|
assert.equal(answer.ok, true)
|
||||||
|
assert.equal(calls.snapshot[0].idempotencyKey, 'k-2')
|
||||||
|
assert.deepEqual(answer.participants.map((p) => p.memberKey), ['0x400150E8', '0x1'])
|
||||||
|
// The one field core will not take on trust: it is a foreign key into `users`,
|
||||||
|
// so a serial passed here would either fail the insert or attribute somebody's
|
||||||
|
// attendance to a stranger.
|
||||||
|
assert.equal(answer.participants[0].userId, 17)
|
||||||
|
assert.equal(answer.participants[1].userId, undefined)
|
||||||
|
// The score is opaque to core; the components are carried so a results table
|
||||||
|
// can say why somebody scored what they did.
|
||||||
|
assert.deepEqual(answer.participants[0].meta, {
|
||||||
|
name: 'Darrow', seconds: 3600, minutes: '60.00', kills: 3,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a webId that is not a positive integer resolves to nothing at all', () => {
|
||||||
|
for (const bad of [null, undefined, '', 'abc', '0', '-3', '1.5', {}]) {
|
||||||
|
assert.equal(actions.webUserId(bad), undefined, JSON.stringify(bad))
|
||||||
|
}
|
||||||
|
assert.equal(actions.webUserId('17'), 17)
|
||||||
|
assert.equal(actions.webUserId(17), 17)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a busy shard is retried, because the work is happening', async () => {
|
||||||
|
// 425 is `bridge.busy`: a snapshot of this run is already walking across Core
|
||||||
|
// ticks. Transient by construction, and deliberately not in PERMANENT_STATUSES.
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 425,
|
||||||
|
data: { kind: 'bridge.busy', reason: 'a command under this key is in flight' },
|
||||||
|
})
|
||||||
|
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
assert.equal(answer.retry, true)
|
||||||
|
|
||||||
|
// Where the event plane simply being switched off is not: 403 is an operator's
|
||||||
|
// deliberate refusal and will still be true in sixty seconds.
|
||||||
|
uoLinkClient.snapshotParticipation = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
data: { kind: 'participation.error', reason: 'the event plane is disabled on this shard' },
|
||||||
|
})
|
||||||
|
const off = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||||
|
assert.equal(off.retry, false)
|
||||||
|
// And the shard's own words reach the run log, because for an event that ran at
|
||||||
|
// four in the morning that log is the only place anyone will learn why.
|
||||||
|
assert.match(off.error, /event plane is disabled/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a dry run of collect reads nothing', async () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2', verify: true }),
|
||||||
|
{ ok: true },
|
||||||
|
)
|
||||||
|
assert.equal(calls.snapshot.length, 0)
|
||||||
|
})
|
||||||
@@ -246,6 +246,14 @@ const KIND_FEATURE = new Map(
|
|||||||
// needs it live. An admin can turn it on.
|
// needs it live. An admin can turn it on.
|
||||||
'vendor.listing': 'market',
|
'vendor.listing': 'market',
|
||||||
'vendor.listing.remove': '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.
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,72 @@ const adminUnban = ({ actor, account }) =>
|
|||||||
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
|
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
|
||||||
call('/admin/broadcast', { method: 'POST', body: { 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) ────────────────────────────────
|
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||||
const respondPage = (pageId, { message, close }) =>
|
const respondPage = (pageId, { message, close }) =>
|
||||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||||
@@ -256,6 +322,12 @@ module.exports = {
|
|||||||
deleteTownCrier,
|
deleteTownCrier,
|
||||||
postNews,
|
postNews,
|
||||||
deleteNews,
|
deleteNews,
|
||||||
|
getLeases,
|
||||||
|
applyLease,
|
||||||
|
releaseLease,
|
||||||
|
openParticipation,
|
||||||
|
snapshotParticipation,
|
||||||
|
closeParticipation,
|
||||||
adminKick,
|
adminKick,
|
||||||
adminBan,
|
adminBan,
|
||||||
adminUnban,
|
adminUnban,
|
||||||
|
|||||||
Reference in New Issue
Block a user