Files
Module-uo/server/config/uoEventActions.js
wtclaude d6346996d3
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Successful in -21s
fix(atlas): keep the UniqueId, and make a landmark value name one landmark
Two defects the Phase 16b re-verify found in the released v1.2.1 bundle, both
of which make a shipped feature unusable and neither of which any test saw.

## The aggregator discarded the UniqueId

`shard_spawn_points.unique_id` was NULL on all 6,455 rows of a stock 57.4 tree.
`listSpawners` filters `unique_id IS NOT NULL`, so `uo.options.spawners` was an
empty dropdown -- and it is the ONLY option source for the Phase 12b
object-property leases, so no `Spawner.MaxCount` / `MinDelay` / `MaxDelay` lease
could be authored at all, with nothing on the form to say why.

Every part of the path was already right except one line. The spawn files carry
`<UniqueId>` (~6,374 of them), `parsePoints` returns it, the column exists and
the insert passes `p.uniqueId || null`. `buildAtlas` rebuilds each point from an
explicit field list and `uniqueId` was not on it -- the word appears nowhere in
that file. `PARSER_VERSION = 4`'s own note says "a spawn point keeps its
UniqueId, which is what a property lease targets", so the intent shipped as a
comment while the code dropped the field one function later.

`PARSER_VERSION` goes to 5 because the bump is the only thing that re-reads an
already-imported tree: `sameSources` compares the tree's hashes, which have not
changed -- only what is kept from them. Confirmed on the rig, where the boot
after the fix logged `spawn atlas refreshed` on an unchanged tree and the manual
import then correctly answered `unchanged`.

## A landmark option value named 23 places at once

A stock tree has 558 landmarks under 320 distinct `facet/name` pairs.
`Trammel/Entrance` is 23 different dungeons -- Blighted Grove, Covetous, Deceit,
Despise, Destard and so on -- and `landmarkPoint` resolved with `.find()`, so 22
of the 23 were unreachable. An author who picked "Entrance - Destard" got
Blighted Grove, and the run succeeded with no warning. The group was already the
disambiguator: it was shown in the dropdown and left out of the value.

The value is now `facet/group/name`, which is distinct across all 558.
`landmarkPoint` tries that form first and keeps the two-part read as a fallback,
because every event published before this fix stores `facet/name` and a
published version is immutable -- refusing to parse those would break runs
rather than correct them. The fallback keeps the old first-match behaviour
deliberately: it is imprecise in exactly the way it always was, and silently
relocating a live event's spawn point is worse than repeating a known
imprecision. A three-part value whose group is gone REFUSES rather than falling
back to the name, because it asked for one particular place.

## Verification

On the released-artefact rig (installer -> bundle 2026.09.10 -> stock 57.4 tree
-> protocol-7 sidecar -> core at main with this module):

  spawn points     6455 rows, 6364 with a unique_id   (was 0)
  uo.options.spawners   100 options, and `?q=orc` searches them   (was 0)
  uo.options.landmarks  558 options, 558 distinct values          (was 320)
  suite            625 pass, 0 fail

Each new test was confirmed to FAIL without its fix. The atlas one asserts the
field on the AGGREGATOR's output rather than the parser's, which is the whole
point of it -- and the test fixture had no `<UniqueId>` at all until now, which
is exactly why a green suite said nothing. The landmark one asserts an
INEQUALITY between two resolved points rather than a literal value string, so it
survives another change of format as long as two options still address two
places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 20:59:49 -05:00

2315 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── module-uo's event verbs ────────────────────────────────────────────────
//
// EVENTS.md §F. Every action an event author can put in a step that reaches the
// game, the budget dimensions that bound them, the leases they may borrow, and
// the option sources the atlas answers.
//
// Three waves, and they are genuinely different kinds of thing:
//
// Phase 9 (wave 1) — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post`.
// Announcements. Nothing in the world changes.
// Phase 11b — `uo.participation.*` and the one config lease. The
// shard watches, and the website borrows a value.
// Phase 12a (wave 2) — the five WORLD verbs: creatures, a boss, an oracle, a
// gate, decoration. Things appear, and this run owns them
// until teardown. See the section above `ACTIONS`.
//
// The header below is wave 1's, and its three rules still govern everything
// here — rule 1 in particular, which is why every action declares `budgetMs`.
//
// **Nothing here is new plumbing.** `uoLinkClient` has carried `adminBroadcast`,
// `postTownCrier`/`deleteTownCrier` and `postNews`/`deleteNews` since protocol
// 2.1; the admin screens have driven all three by hand for months. What this file
// adds is the declaration that lets the event engine drive them unattended —
// which is a different question, and the reason most of this file is about what
// happens when a call does NOT come back.
//
// ── The three rules that shape every declaration below ─────────────────────
//
// **1. `budgetMs` must exceed the client's own timeout, or the module never gets
// to classify its own failure.** `dispatch.classify()` answers `retry` for a
// budget timeout unconditionally and a module cannot override that — the module
// is not asked, because it is still awaiting a socket. `uoLinkClient.TIMEOUT_MS`
// is 12s and core's `DEFAULT_BUDGET_MS` is 10s, so on default settings core's
// deadline fires FIRST on every slow shard and the step is retried. Every action
// here therefore declares `budgetMs: 15000`: the client always answers first, and
// what the runner acts on is this file's judgement rather than a race.
//
// That is not a tuning detail. It is the whole of what makes rule 2 true.
//
// **2. A broadcast is retried, and protocol 6 is what changed that.** Wave 1
// shipped `uo.broadcast` answering `retry: false` to everything, because a
// retried broadcast was a second announcement to everyone online and nothing on
// the wire could make the shard refuse the repeat. A lost announcement was
// cheaper than a doubled one, and that was the whole argument.
//
// Protocol 6 removes its premise. Every write below now carries the step's
// `idempotencyKey`; the shard executes a key at most once and answers a repeat
// with the ORIGINAL reply rather than re-running it. So a retry of a broadcast
// whose acknowledgement was lost cannot announce twice — it collects the answer
// the first attempt never delivered. A shard restarting mid-run is now recovered
// from rather than written off, which is the case rule 2 used to throw away
// knowingly.
//
// Rule 1 is what keeps this true rather than merely intended: if core's deadline
// fired first the module would never be asked, and the retry would be core's
// unconditional one — carrying the same key, so still safe, but classified
// without the module's judgement.
// **2a. The one status that is new here.** A repeat arriving while the original
// is still in flight on the shard is answered `bridge.busy`, which the sidecar
// maps to **425**. It is transient by construction: the work is happening. It is
// not in `PERMANENT_STATUSES` and `classify()` falls through to retry, so it
// needs no arm of its own — but it is named so that a future tightening of that
// list has to decide about it deliberately.
//
// **3. What a shard restart wipes, `reconcile()` reports gone — and it knows
// which restart it was without asking.** There is no "list the town-crier lines"
// or "list the news articles" on the wire, and adding one would be protocol work
// for a question the module can already answer: a town-crier line and an
// event-owned news article both live in shard memory, so a restart is
// definitionally the loss of both. `perform()` stamps the shard's `bootId` into
// the resource payload and `reconcile()` reports in force exactly the rows whose
// stamp still matches. That is correct for BOTH callers — the module's own
// `ctx.events.reconcile()` on a changed `bootId`, and core's boot-time sweep,
// where the shard may not have restarted at all and answering "all gone" would
// abandon live rows.
const core = require('../core')
const uoLinkClient = require('../utils/uoLinkClient')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
const { classify: classifySidecarWrite } = require('../utils/shardAnnounce')
const log = core.logger('uo-events')
// See rule 1 in the header. Above `uoLinkClient.TIMEOUT_MS` (12s), below core's
// `MAX_BUDGET_MS` (1h) by a mile.
const BUDGET_MS = 15000
// The sidecar's own caps, mirrored from the admin routes that already validate
// against them (`admin/uoLink.router.js` for the crier, `admin/shard.router.js`
// for the broadcast). Pre-checked here so an over-long line is a refusal a DRY
// RUN can show the author, rather than a 400 mid-run.
const MAX_BROADCAST_LEN = 300
const MAX_CRIER_LINES = 8
const MAX_CRIER_LINE_LEN = 200
const MAX_CRIER_DURATION_SEC = 86400
const MAX_NEWS_TITLE = 120
const MAX_NEWS_BODY = 900
// How many options one source will answer with. Real UO facets carry a few
// hundred regions and landmarks and ~800 constructible creature types, so this is
// comfortably above the data rather than a guess at it — and a deployment that
// exceeds it gets a log line naming the source and the counts, because a dropdown
// that silently omits the landmark an author is looking for is the defect this
// bound would otherwise introduce.
const MAX_OPTIONS = 2000
/**
* The id both keyed verbs post under.
*
* The step's idempotency key is `sha256(runId|stepId)` truncated to 40 hex — a
* function of identity and never of attempt — so a retry re-posts the SAME id and
* the sidecar replaces rather than stacks. That is the property that makes the
* crier and the news gump safe to retry and the broadcast not.
*
* **The `evt-` prefix is load-bearing for news.** `newsGump.js` posts articles
* under the bare website post id (`String(post.id)`) and `reassertAll()` re-pushes
* that whole set on every sidecar reconnect. An event article numbered into the
* same space would be a collision with a post — silently, and in whichever
* direction wrote last. 4 + 40 characters, inside the sidecar's 64-char cap.
*/
const resourceId = (idempotencyKey) => `evt-${idempotencyKey}`
/** The shard boot this write belongs to, or null when nothing has connected yet. */
async function currentBootId() {
try {
const config = await uoLinkConfig.getSafe()
return config.bootId || null
} catch (err) {
// Never fatal to a world write. A missing stamp means `reconcile()` cannot
// vouch for the row, which leaves core believing its own ledger — the
// pre-Phase-8 behaviour, and the right way to be wrong.
log.warn('could not read the shard boot id for an event resource', { error: err.message })
return null
}
}
// Statuses that will never succeed however many times they are tried: a data
// refusal, a bad token, a switched-off write plane, a protocol mismatch. Named
// here rather than folded into `shardAnnounce.classify` because 403 is reachable
// only from the `/admin/*` verbs — the announce leg posts to the town crier,
// which the admin write plane does not gate — and widening a shared classifier
// for a case its own caller cannot produce is how a shared rule stops being one.
const PERMANENT_STATUSES = new Set([400, 401, 403, 404, 409])
/**
* What the shard actually said, in its own words.
*
* **The sidecar explains its refusals and `legError` drops the explanation**, and
* this was worth its own helper the moment an event started making these calls
* unattended. A `403` body reads `{"reason":"admin write plane disabled"}`;
* `legError` looks for `data.message`, finds nothing, and falls back to "sidecar
* responded 403". For a staff member clicking a button that is survivable — they
* know what they just switched off. For an event that ran at four in the morning,
* the run log is the only place anyone will ever learn why, and "403" is not an
* answer an operator can act on.
*/
function sidecarReason(result, what) {
const data = (result && result.data) || {}
return data.reason || data.message || (result && result.error) || `the shard refused the ${what}`
}
/**
* The sidecar's answer, as an event outcome.
*
* **The announce leg's classification, not a second opinion.** `shardAnnounce`
* already decides what each status from this transport means — 400 a data
* problem, 401/409 a config problem, everything else transient — and it decides
* it about the same sidecar over the same client. Two copies of that judgement is
* how the two drift, which is the argument `core.announce` makes for deferring to
* a leg's own `classify()`.
*/
function sidecarFailure(result, what) {
const { outcome } = classifySidecarWrite(result)
const permanent = PERMANENT_STATUSES.has(result && result.status)
return {
ok: false,
retry: outcome === 'retry' && !permanent,
error: sidecarReason(result, what),
}
}
/** Split an authored text block into crier lines, and say why it is not one. */
function crierLines(raw) {
const lines = String(raw == null ? '' : raw)
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
if (!lines.length) return { ok: false, error: 'the message is empty' }
if (lines.length > MAX_CRIER_LINES) {
return { ok: false, error: `the criers carry ${MAX_CRIER_LINES} lines and this is ${lines.length}` }
}
const over = lines.find((l) => l.length > MAX_CRIER_LINE_LEN)
if (over) {
return {
ok: false,
error: `a crier line is capped at ${MAX_CRIER_LINE_LEN} characters and "${over.slice(0, 40)}…" is ${over.length}`,
}
}
return { ok: true, lines }
}
// ── Budgets ────────────────────────────────────────────────────────────────
//
// One dimension, and only `uo.broadcast` spends it (org lead, 2026-09-04). A run
// that broadcasts forty times is the spam failure mode, and a per-run cap is the
// only thing standing between an authoring mistake and every player online. The
// two keyed verbs get none: they are posted under a run-scoped id and a repeat
// REPLACES, so the thing a cap would guard against does not exist for them.
const BUDGETS = [
{
id: 'uo.broadcasts',
label: 'Global broadcasts',
unit: 'broadcasts',
description: 'System messages this run may put in front of everyone online.',
},
// The world verbs (Phase 12a). These are the MODULE's dimensions, not core's
// (org lead, 2026-09-07): core meters whatever a module declares and holds no
// UO knowledge, which is the whole of what §F means by game-agnostic. Their
// defaults are the EM Program's published quotas.
//
// Every one of them is also bounded independently on the shard
// (`Bridge.EventsMax*`), which REFUSES rather than clamps. Two bounds is not
// belt and braces: an administrator raising a budget here is saying what a run
// may spend, and the operator's ceiling is saying what their world will take.
{
id: 'uo.creatures',
label: 'Creatures spawned',
unit: 'creatures',
description: 'Creatures this run may put into the world. Each one is deleted at teardown.',
},
{
id: 'uo.bosses',
label: 'Bosses spawned',
unit: 'bosses',
description: 'Enhanced creatures this run may put into the world.',
},
{
id: 'uo.npcs',
label: 'Oracle NPCs placed',
unit: 'NPCs',
description: 'Speaking NPCs this run may stand up at its venue.',
},
{
id: 'uo.decor',
label: 'Decoration placed',
unit: 'items',
description: 'Scenery items this run may place. Immovable, and removed at teardown.',
},
{
// A duration rather than a count, because one gate standing all day and
// twelve standing five minutes each are not the same imposition on a world,
// and a count would price them identically.
id: 'uo.gate.minutes',
label: 'Gate minutes',
unit: 'minutes',
description: 'Total minutes of temporary gate this run may open, across every gate.',
},
{
id: 'uo.rewards',
label: 'Items granted',
unit: 'items',
description:
'Items handed to participants. Counted per item rather than per grant: a step giving 500 gold to 40 people is a different imposition from one giving a candle to 40 people, and a count of grants would price them the same.',
},
]
// ── 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. */
function landmarkValue(row) {
const group = row.group || ''
return group === '' ? `${row.facet}/${row.name}` : `${row.facet}/${group}/${row.name}`
}
/**
* A place string resolved to a point on a facet.
*
* **Two forms, and the older one is not deprecated — it is stored.** The current
* form is `facet/group/name`, which names exactly one landmark. The older
* `facet/name` is what every event published before this fix carries, and those
* rows are the authored record: a published version is immutable, so a parse that
* stopped understanding them would break runs rather than correct them. So the
* three-part form is tried first and the two-part read is the fallback.
*
* The fallback keeps the old first-match behaviour deliberately. It is wrong in
* the same way it always was — that is what the new form exists to fix — but it
* is what those runs did last time, and silently relocating a live event's
* spawn point is worse than repeating a known imprecision.
*
* A three-part value whose group no longer exists REFUSES rather than falling
* back to the name alone, and that is the point rather than a gap: it asked for
* one particular landmark, so the honest answer when that landmark is gone is to
* say so — the operator renamed something and an event needs re-pointing. Only a
* value that never named a group gets the imprecise read.
*/
async function landmarkPoint(value) {
const raw = String(value == null ? '' : value)
const parts = raw.split('/')
if (parts.length < 2 || parts[0] === '' || parts[parts.length - 1] === '') {
return { ok: false, error: `"${raw}" is not a facet/name place` }
}
const facet = parts[0]
const rows = await shardAtlas.listLandmarks({ facet })
// `facet/group/name`. The name is the LAST segment and the group is everything
// between, so a group carrying a slash still resolves.
if (parts.length >= 3) {
const group = parts.slice(1, -1).join('/')
const name = parts[parts.length - 1]
const hit = rows.find((r) => r.name === name && (r.group || '') === group)
if (hit) return { ok: true, map: hit.facet, x: hit.x, y: hit.y }
// No fall-through error: a name containing a slash reads as three parts too,
// and the two-part read below is the one that resolves it.
}
const name = parts.slice(1).join('/')
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 }
}
// ── The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a) ──────────────
//
// Five verbs an author sees, and ONE command family underneath them, because
// each of them ends in the same sentence: an object exists, and this run owns
// it. So `perform`, `revert` and `reconcile` are written once here and the five
// declarations below differ only in what they validate and what they send.
//
// **`revert` and `reconcile` are shared but not interchangeable with the rest of
// this file's**, and the difference is the phase's headline. A crier line and a
// news article live in shard memory, so a changed `bootId` IS proof they are
// gone and `reconcileByBootId` can answer without asking. A spawned creature is
// in the world SAVE. It survives the restart the boot stamp would report it lost
// by, so the only honest answer is to ask the shard what it still holds — which
// is what `world.owned` is for, and why it prunes as it walks.
/** The ledger kind every world verb files its serials under. */
const OWNED_KIND = 'world'
// Mirrors of the shard's own default ceilings (`Bridge.EventsMax*`), pre-checked
// here so an over-large step is a refusal a DRY RUN can show the author rather
// than a 400 arriving mid-run. The shard's are authoritative and an operator may
// set them lower, in which case its refusal is the one that lands \u2014 which is
// correct: these are a courtesy, not the bound.
const MAX_CREATURES = 30
const MAX_BOSSES = 4
const MAX_NPCS = 5
const MAX_DECOR = 60
/** The widest scatter an author may ask for, mirroring the shard's own bound. */
const MAX_SPREAD = 40
/** How much harder than a normal creature a boss may be made. */
const MAX_BOSS_MULTIPLIER = 10
/** How many keyword lines one oracle answers to. */
const MAX_ORACLE_LINES = 5
/** The longest a temporary gate may stand, in minutes. */
const MAX_GATE_MINUTES = 240
/** Read a count param, or a refusal an author can act on. */
function counted(raw, ceiling, what) {
const count = raw === undefined || raw === null || raw === '' ? 1 : Number(raw)
if (!Number.isInteger(count) || count < 1 || count > ceiling) {
return { ok: false, error: `place 1 to ${ceiling} ${what} at a time, and "${raw}" is not that` }
}
return { ok: true, count }
}
/** Read an optional positive-integer param (a hue, a spread), or a refusal. */
function optionalInt(raw, { max, name }) {
if (raw === undefined || raw === null || raw === '') return { ok: true, value: undefined }
const value = Number(raw)
if (!Number.isInteger(value) || value < 0 || (max !== undefined && value > max)) {
return { ok: false, error: `"${raw}" is not a ${name} this shard will take` }
}
return { ok: true, value }
}
/** Read an optional multiplier, or a refusal. */
function multiplier(raw, name) {
if (raw === undefined || raw === null || raw === '') return { ok: true, value: undefined }
const value = Number(raw)
if (!Number.isFinite(value) || value < 1 || value > MAX_BOSS_MULTIPLIER) {
return {
ok: false,
error: `a ${name} is 1 to ${MAX_BOSS_MULTIPLIER} times normal, and "${raw}" is not`,
}
}
return { ok: true, value }
}
/**
* Parse an oracle's dialogue out of one textarea.
*
* One row per line, `keywords = what it says`, split on the FIRST `=` so the
* answer may contain one and the keywords may not:
*
* fire, flame = The flame you seek burns beneath the keep.
* gate = A gate will open at dusk, by the bank.
*
* A textarea rather than five pairs of fields because the action param types are
* scalars (`string`, `int`, `float`, `boolean`, `datetime`, `url`) and there is
* no array among them — and because §G's whole claim about this verb is that it
* is a web form. Ten numbered fields would be a worse one than a text box.
*/
function oracleLines(raw) {
const rows = []
const text = raw === undefined || raw === null ? '' : String(raw)
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed === '') continue
const cut = trimmed.indexOf('=')
if (cut < 1) {
return { ok: false, error: `"${trimmed}" is not "keywords = what to say"` }
}
const keywords = trimmed
.slice(0, cut)
.split(',')
.map((word) => word.trim())
.filter(Boolean)
const say = trimmed.slice(cut + 1).trim()
if (!keywords.length || !say) {
return { ok: false, error: `"${trimmed}" needs both a keyword and something to say` }
}
rows.push({ keywords: keywords.join(','), text: say })
}
if (rows.length > MAX_ORACLE_LINES) {
return {
ok: false,
error: `an oracle answers to at most ${MAX_ORACLE_LINES} things, and this gives ${rows.length}`,
}
}
return { ok: true, rows }
}
/**
* Place something, and file every serial the shard hands back.
*
* One resource per SERIAL rather than one per call, so a group half of which a
* player killed reconciles per creature instead of all-or-nothing. The payload
* carries what it was, because a ledger row reading "this run owned something
* and it is gone" is worth less to an operator than one naming the orc.
*/
async function placeOwned({ runId, idempotencyKey, what, body }) {
const result = await uoLinkClient.spawnWorld({
runId: String(runId),
what,
idempotencyKey,
...body,
})
if (!result.ok) return sidecarFailure(result, `${what} placement`)
const serials = Array.isArray(result.data && result.data.serials) ? result.data.serials : []
return {
ok: true,
resources: serials.map((serial) => ({
kind: OWNED_KIND,
ref: String(serial),
payload: { runId: String(runId), what, type: body.type || what, name: body.name || null },
})),
}
}
/**
* Give back what this step placed.
*
* `gone` is not reported at all, deliberately: a creature a player killed is the
* point of having spawned it, and §L already says "gone, and that is fine" is a
* successful revert. `refused` IS reported, as `failed`, because it means the
* shard denies this run ever owned that serial — nothing will ever delete it
* through this path, so the row must land unresolved with a reason rather than
* be quietly marked reverted.
*
* **The despawn carries NO idempotency key, and that is the whole point.** This
* function used to forward core's `idempotencyKey` as the despawn's own — which
* is the step's key, the very key `placeOwned` spawned under. The shard's
* at-most-once store is keyed on the key ALONE (`BridgeIdempotency.Intercept`
* does `_byKey.TryGetValue(key, …)`, not a lookup by key AND command), so the
* despawn was recognised as a repeat and answered with the SPAWN's stored reply.
* `OnDespawn` never ran, core saw `ok` with no `refused`, and every row was
* marked `reverted` while the shard still held every object. Teardown of all
* five world verbs was a no-op that reported success.
*
* No key is needed here. A repeat despawn is already safe by the handler's own
* three-answer design: the second pass finds the serial gone and answers `gone`,
* which is a success on both ends. `MODULE_API.md` says what core's key is FOR,
* and it is not this — it identifies a dispatch core never learned the outcome
* of, so the module can ask about it. That case arrives here as an EMPTY
* `resources` list, and it is answered correctly by the same call: no serials
* means "everything this run owns", which is exactly the right sweep.
*/
async function revertOwned({ runId, resources }) {
const result = await uoLinkClient.despawnWorld({
runId: String(runId),
serials: resources.map((resource) => resource.ref),
})
if (!result.ok) return { ok: false, error: sidecarReason(result, 'despawn') }
const refused = Array.isArray(result.data && result.data.refused)
? result.data.refused.map(String)
: []
return refused.length ? { ok: true, failed: refused } : { ok: true }
}
/**
* Ask the shard what this run still owns.
*
* NOT `reconcileByBootId`. See the section header: these resources are in the
* world save and survive the restart the boot stamp would report them lost by.
*
* Anything the shard does not list is gone, and that is a safe reading only
* because the registry and the objects it describes are written by the SAME
* world save — they cannot get out of step with each other. An unreachable or
* refusing shard has said nothing, so the whole group is left alone.
*/
async function reconcileOwned({ runId, resources }) {
const result = await uoLinkClient.ownedWorld({ runId: String(runId) })
if (!result.ok) return { ok: false, error: sidecarReason(result, 'owned') }
const rows = Array.isArray(result.data && result.data.owned) ? result.data.owned : []
const held = new Set(rows.map((row) => String(row.serial)))
return { ok: true, inForce: resources.filter((r) => held.has(r.ref)).map((r) => r.ref) }
}
/** The three fields every world verb shares, so five declarations cannot drift apart. */
// ── The one-shots' vocabulary (Phase 12b) ──────────────────────────────────
/**
* What this shard will grant, mirroring the plugin's own allowlist.
*
* **Two copies of a short list, deliberately.** This one is what makes a bad
* value a refusal on an authoring form; the plugin's is what is true when this
* one is wrong — the same argument the lease bounds have carried since 11b. It
* is held here rather than read from `GET /items` because §F is explicit that an
* option source must resolve with the game side down, and an authoring screen a
* shard outage can make unusable is a worse failure than the typo the dropdown
* prevents.
*
* `stackable` is carried because it changes what an `amount` MEANS: five gold is
* one item with `Amount = 5`, and five cloaks would be five items — five chances
* to overflow a backpack halfway through with no way to say which half landed.
* Both ends refuse a non-stackable in quantity.
*/
const GRANTABLE = [
{ key: 'gold', label: 'Gold', stackable: true },
{ key: 'cloak', label: 'Cloak', stackable: false },
{ key: 'sandals', label: 'Sandals', stackable: false },
{ key: 'candle', label: 'Candle', stackable: false },
{ key: 'earrings', label: 'Silver earrings', stackable: false },
{ key: 'fireworks', label: 'Fireworks wand', stackable: false },
{ key: 'bottle', label: 'Message in a bottle', stackable: false },
]
/** The bound on one hand, mirroring `Bridge.EventsMaxGrantStack`. */
const MAX_GRANT_STACK = 1000
/**
* The seasonal events a lease may name — eight of `EventType`'s nine.
*
* `TreasuresOfTokuno` is absent because `SeasonalEventEntry.IsActive()`
* special-cases it and reads `TreasuresOfTokuno.DropEra` rather than `Status`,
* so a lease on it would write a field nothing consults: the write succeeds, the
* value reads back, the compare-and-set restore passes, and nothing in the world
* changes. §N10 calls that "a capability that lies", and it is the one instance
* no runtime probe can catch — which is why it is excluded by name at both ends.
*/
const SEASONAL_EVENTS = [
'VirtueArtifacts',
'TreasuresOfKotlCity',
'SorcerersDungeon',
'TreasuresOfDoom',
'TreasuresOfKhaldun',
'KrampusEncounter',
'RisingTide',
'Fellowship',
]
/** ServUO's own display names for them, from `SeasonalEventSystem.LoadEntries()`. */
const SEASONAL_LABELS = {
VirtueArtifacts: 'Virtue Artifacts',
TreasuresOfKotlCity: 'Treasures of Kotl',
SorcerersDungeon: "Sorcerer's Dungeon",
TreasuresOfDoom: 'Treasures of Doom',
TreasuresOfKhaldun: 'Treasures of Khaldun',
KrampusEncounter: 'Krampus Encounter',
RisingTide: 'Rising Tide',
Fellowship: 'Fellowship',
}
/**
* How many spawners one search answers with.
*
* Well under `MAX_OPTIONS` on purpose: this is a typeahead rather than a select,
* and a hundred rows is already more than anybody reads. The bound that matters
* is that the SEARCH reaches the whole tree, which it does.
*/
const SPAWNER_OPTIONS = 100
const OWNED_COMMON = {
// Something appears in the world where there was nothing. §K's default-off
// line falls between `inspect` and `change`, so an operator switches these on
// deliberately — which is the right consent for a scheduled, unattended
// change to a live world.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
revert: revertOwned,
reconcile: reconcileOwned,
}
/**
* The body creatures and bosses share: a resolved place, a validated type, and
* the optional dressing. The boss verb adds its multipliers on top.
*
* The creature is named by its ServUO TYPE, which is what
* `uo.options.creatures` now answers with \u2014 see the option source. The atlas
* slug would be unusable here: the shard constructs from a class name, and a
* value an author picks that the shard cannot act on is not a value.
*/
async function creatureBody(params, ceiling) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, error: place.error }
const howMany = counted(params.count, ceiling, 'creatures')
if (!howMany.ok) return { ok: false, error: howMany.error }
const type = String(params.creature || '').trim()
if (!type) return { ok: false, error: 'pick a creature' }
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, error: hue.error }
const spread = optionalInt(params.spread, { max: MAX_SPREAD, name: 'spread' })
if (!spread.ok) return { ok: false, error: spread.error }
return {
ok: true,
value: {
map: place.map,
x: place.x,
y: place.y,
count: howMany.count,
type,
name: String(params.name || '').trim() || undefined,
hue: hue.value,
spread: spread.value,
},
}
}
/** The `place` param, shared by every verb: where in the world this happens. */
const PLACE_PARAM = {
name: 'place',
type: 'string',
required: true,
example: 'Felucca/Britain',
source: 'uo.options.landmarks',
description: 'Where this happens.',
}
// ── Actions ────────────────────────────────────────────────────────────────
const ACTIONS = [
{
id: 'uo.broadcast',
label: 'Broadcast to everyone online',
description:
'Puts one system message in front of every player currently logged in. Sent once and never retried — a repeat would be a second announcement, and until the shard can refuse a duplicate there is no way to take one back.',
// Nothing in the world changes and nothing is created; a message goes out.
// Same class as `core.announce`, and for the same reason — which also gives
// it `skip` as its default disposition, so a run does not stop over an
// announcement that did not go out.
risk: 'notify',
// There is no undo, and declaring `ledger` would put a row in the cleanup
// ledger that teardown could never resolve.
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
cost: () => ({ 'uo.broadcasts': 1 }),
params: [
{
name: 'text',
type: 'string',
required: true,
example: 'The gates of Britain open at dusk. Gather at the bank.',
description: `The message, up to ${MAX_BROADCAST_LEN} characters.`,
},
{
name: 'hue',
type: 'int',
required: false,
example: 1153,
description: 'UO colour id for the message. Left out, the shard uses its system colour.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const text = String(params.text == null ? '' : params.text).trim()
// Checked here rather than left to the sidecar's 400, so the DRY RUN shows
// the author the refusal — which is the whole point of having one.
if (!text) return { ok: false, retry: false, error: 'the message is empty' }
if (text.length > MAX_BROADCAST_LEN) {
return {
ok: false,
retry: false,
error: `a broadcast is capped at ${MAX_BROADCAST_LEN} characters and this is ${text.length}`,
}
}
if (verify) return { ok: true }
// `event:<runId>` (org lead, 2026-09-04). The shard records an actor on
// every staff write and echoes it back as an `admin.audit` event, so this
// is what an operator reads in the game's own audit trail afterwards. No
// staff member pressed a button — attributing it to one would be a false
// record — and the run id is the thing that makes the line actionable.
const result = await uoLinkClient.adminBroadcast({
actor: `event:${runId}`,
text,
hue: params.hue === undefined || params.hue === null ? undefined : Number(params.hue),
// Protocol 6, and the line rule 2 said would change. The key is the
// step's, so every attempt at this step carries the same one and the
// shard refuses the repeat — which is what makes the retry below safe to
// ask for at all.
idempotencyKey,
})
if (result.ok) return { ok: true }
// **A transient failure is now retried**, where wave 1 gave up on it. What
// used to make a retry unsafe was that the shard could not tell a repeat
// from a fresh command; it can now, so a 503 from a shard that is merely
// restarting is recovered from instead of being written off.
//
// The classification itself is `sidecarFailure`'s — the announce leg's own
// judgement about this transport, deferred to rather than second-guessed,
// exactly as the two keyed verbs below already do. That this action now
// uses the SAME helper as its siblings, instead of a hand-rolled variant
// that forced every outcome terminal, is most of the change here.
return sidecarFailure(result, 'broadcast')
},
},
{
id: 'uo.towncrier.post',
label: 'Post to the town criers',
description:
'Puts up to eight lines in the mouths of the town criers for a set time, and takes them down again when the event ends.',
// `notify` is about what a FAILURE costs — nothing is half-changed and the
// run should carry on — while `ledger` is about what SUCCESS leaves behind.
// The two are independent questions and this is the combination where that
// shows: an announcement that can be withdrawn.
risk: 'notify',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
// **One text block, not a list, because the param vocabulary has no
// array type** (`VARIABLE_TYPES` is string/int/float/boolean/datetime/url).
// Splitting on newlines is the honest encoding of eight short lines in a
// textarea, and the caps are checked before anything is sent.
name: 'lines',
type: 'string',
required: true,
example: 'Hear ye! The Britain gates open at dusk.\nSeek the herald by the bank.',
description: `One line per newline. Up to ${MAX_CRIER_LINES} lines of ${MAX_CRIER_LINE_LEN} characters.`,
},
{
name: 'durationMinutes',
type: 'int',
required: false,
example: 60,
description: 'How long the criers keep saying it. Left out, the shard keeps it for an hour.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const parsed = crierLines(params.lines)
if (!parsed.ok) return { ok: false, retry: false, error: parsed.error }
let durationSec
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` }
}
durationSec = Math.min(Math.round(minutes * 60), MAX_CRIER_DURATION_SEC)
}
if (verify) return { ok: true }
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
// Protocol 6. This verb was already safe to retry — a repeat under the same
// `id` REPLACES the crier entry rather than stacking a second one — so the
// key buys no new safety here. It is sent because it costs nothing and
// makes the retry a no-op on the shard rather than a redundant world write,
// and because a write plane where only some commands are keyed is one
// somebody will later have to reason about per verb.
const result = await uoLinkClient.postTownCrier({
id,
lines: parsed.lines,
durationSec,
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'town-crier post')
// The stamp rule 3 rests on. `runId` rides along so a row read out of the
// ledger says which run put it up without a join.
return { ok: true, resources: [{ kind: 'towncrier', ref: id, payload: { bootId, runId } }] }
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.deleteTownCrier(resource.ref)
// §L: "gone, and that is fine" is a successful revert. A crier line whose
// duration simply ran out is a 404, and it is the outcome we wanted.
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
reconcile: reconcileByBootId,
},
{
id: 'uo.news.post',
label: 'Post an article to the news gump',
description:
"Puts an article in the in-game Town Cryer news window for the life of the event, and pulls it when the event ends. Separate from the site's own news posts, which sync there on their own.",
risk: 'notify',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{
name: 'title',
type: 'string',
required: true,
example: 'The Britannian Midsummer Fair',
description: `The article heading, up to ${MAX_NEWS_TITLE} characters.`,
},
{
name: 'body',
type: 'string',
required: true,
example: 'Merchants from every city gather in Britain for three days of trade and contest.',
description: `The article, up to ${MAX_NEWS_BODY} characters. Plain text; the gump renders a small HTML subset and this is wrapped for it.`,
},
{
name: 'url',
type: 'url',
required: false,
example: 'https://example.com/site/events',
description: "The article's \"more info\" link. Left out, the gump shows no link.",
},
{
name: 'image',
type: 'int',
required: false,
example: 5013,
description: 'A shard art id to illustrate the article. Left out, the sidecar uses a neutral scroll.',
},
{
name: 'announce',
type: 'boolean',
required: false,
example: true,
description: 'Whether the criers proclaim the title when it goes up. Left out, they do.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const title = String(params.title == null ? '' : params.title).replace(/\s+/g, ' ').trim()
const body = String(params.body == null ? '' : params.body).trim()
if (!title) return { ok: false, retry: false, error: 'the article has no title' }
if (title.length > MAX_NEWS_TITLE) {
return {
ok: false,
retry: false,
error: `a news title is capped at ${MAX_NEWS_TITLE} characters and this is ${title.length}`,
}
}
if (!body) return { ok: false, retry: false, error: 'the article has no body' }
if (body.length > MAX_NEWS_BODY) {
return {
ok: false,
retry: false,
error: `a news body is capped at ${MAX_NEWS_BODY} characters and this is ${body.length}`,
}
}
if (verify) return { ok: true }
const id = resourceId(idempotencyKey)
const bootId = await currentBootId()
const result = await uoLinkClient.postNews({
id,
title,
// The same gump-HTML shape `newsGump.buildArticle` uses, so an event
// article and a site article read alike in the window they share.
body: `<CENTER>${title}</CENTER><BR><BR>${body}`,
image:
params.image === undefined || params.image === null ? undefined : Number(params.image),
url: params.url || undefined,
announce: params.announce === undefined || params.announce === null ? true : Boolean(params.announce),
// Protocol 6, for the same reason the crier carries one — except that
// here it does buy something. `announce: true` makes the criers proclaim
// the article's title when it is posted, so a re-post under the same id
// replaces the article silently but proclaims it AGAIN. The key stops the
// second proclamation, which was the one part of this verb that was never
// as idempotent as its `id` made it look.
idempotencyKey,
})
if (!result.ok) return sidecarFailure(result, 'news article')
return { ok: true, resources: [{ kind: 'news', ref: id, payload: { bootId, runId } }] }
},
async revert({ resources }) {
const failed = []
for (const resource of resources) {
const result = await uoLinkClient.deleteNews(resource.ref)
if (!result.ok && result.status !== 404) failed.push(resource.ref)
}
if (!failed.length) return { ok: true }
return { ok: true, failed }
},
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,
},
})),
}
},
},
{
id: 'uo.creature.spawn',
label: 'Spawn creatures',
description:
"Puts creatures into the world at a place you choose, optionally renamed and recoloured. Each one is recorded against this run and deleted at teardown \u2014 and a creature players kill in the meantime is an ordinary outcome, not a failure.",
...OWNED_COMMON,
cost: (p) => ({ 'uo.creatures': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'creature',
type: 'string',
required: true,
example: 'Orc',
source: 'uo.options.creatures',
description: "Which creature. The list is what this shard's own spawners actually use.",
},
{ name: 'count', type: 'int', required: true, example: 8, description: 'How many.' },
{
name: 'name',
type: 'string',
required: false,
example: 'Rotting Orc',
description: 'What they are called. Left out, the creature keeps its own name.',
},
{
name: 'hue',
type: 'int',
required: false,
example: 1157,
description: 'UO colour id. Left out, the creature keeps its own colour.',
},
{
name: 'spread',
type: 'int',
required: false,
example: 6,
description: `How many tiles to scatter them across, up to ${MAX_SPREAD}. Left out, they arrive on one tile.`,
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const body = await creatureBody(params, MAX_CREATURES)
if (!body.ok) return { ok: false, retry: false, error: body.error }
if (verify) return { ok: true }
return placeOwned({ runId, idempotencyKey, what: 'creature', body: body.value })
},
},
{
id: 'uo.boss.spawn',
label: 'Spawn a boss',
description:
"An ordinary creature made harder and given a name \u2014 EVENTS.md's \"enhanced regular mob\". The event owns what it created and never touches a creature it did not; there is no verb here that reaches an existing boss.",
...OWNED_COMMON,
cost: (p) => ({ 'uo.bosses': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'creature',
type: 'string',
required: true,
example: 'OrcCaptain',
source: 'uo.options.creatures',
description: 'Which creature to build it from.',
},
{
name: 'name',
type: 'string',
required: true,
example: 'Gruk the Unbroken',
description: 'What it is called. Required here \u2014 an unnamed boss is just a hard orc.',
},
{ name: 'count', type: 'int', required: false, example: 1, description: 'How many.' },
{
name: 'hitsMultiplier',
type: 'float',
required: false,
example: 3,
description: `How much tougher than normal, up to ${MAX_BOSS_MULTIPLIER}.`,
},
{
name: 'damageMultiplier',
type: 'float',
required: false,
example: 1.5,
description: `How much harder it hits, up to ${MAX_BOSS_MULTIPLIER}.`,
},
{
name: 'statMultiplier',
type: 'float',
required: false,
example: 2,
description: `How much its strength, dexterity and intelligence are raised, up to ${MAX_BOSS_MULTIPLIER}.`,
},
{ name: 'hue', type: 'int', required: false, example: 1175, description: 'UO colour id.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const body = await creatureBody(params, MAX_BOSSES)
if (!body.ok) return { ok: false, retry: false, error: body.error }
if (!String(params.name || '').trim()) {
return { ok: false, retry: false, error: 'a boss needs a name' }
}
for (const field of ['hitsMultiplier', 'damageMultiplier', 'statMultiplier']) {
const parsed = multiplier(params[field], field.replace('Multiplier', ' multiplier'))
if (!parsed.ok) return { ok: false, retry: false, error: parsed.error }
if (parsed.value !== undefined) body.value[field] = parsed.value
}
if (verify) return { ok: true }
return placeOwned({ runId, idempotencyKey, what: 'boss', body: body.value })
},
},
{
id: 'uo.npc.place',
label: 'Stand up an oracle',
description:
'A speaking NPC that greets players who come near and answers to words you choose. It cannot be killed, looted or moved, so it is still where this run left it when teardown comes to collect it.',
...OWNED_COMMON,
cost: (p) => ({ 'uo.npcs': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'name',
type: 'string',
required: true,
example: 'Marisa the Seer',
description: 'What it is called.',
},
{
name: 'title',
type: 'string',
required: false,
example: 'the seer',
description: 'A title shown under the name.',
},
{
// **Required, and it was optional until the dry-run sweep caught it.**
// An oracle with neither a greeting nor a line stands there in silence,
// which both ends refuse — so with both fields optional the verb could
// not be performed from its own required params, and no authoring form
// could render it as valid either. A cross-field "at least one of these"
// rule is the wrong shape for a declaration core reads as data; making
// the greeting required says the same thing in the contract itself.
name: 'greeting',
type: 'string',
required: true,
example: 'You have the look of someone with a question.',
description: 'Said once to each player who comes near.',
},
{
name: 'lines',
type: 'string',
required: false,
example: 'fire, flame = The flame you seek burns beneath the keep.',
description: `One per line, "keywords = what to say", up to ${MAX_ORACLE_LINES}. Keywords are separated by commas and matched anywhere in what a player says.`,
},
{
name: 'sex',
type: 'string',
required: false,
example: 'female',
description: '"female" or "male". Left out, male.',
},
{ name: 'count', type: 'int', required: false, example: 1, description: 'How many.' },
{ name: 'hue', type: 'int', required: false, example: 1002, description: 'Skin colour id.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, retry: false, error: place.error }
const howMany = counted(params.count, MAX_NPCS, 'oracles')
if (!howMany.ok) return { ok: false, retry: false, error: howMany.error }
const name = String(params.name || '').trim()
if (!name) return { ok: false, retry: false, error: 'an oracle needs a name' }
const lines = oracleLines(params.lines)
if (!lines.ok) return { ok: false, retry: false, error: lines.error }
const greeting = String(params.greeting || '').trim()
// Refused HERE as well as on the shard, because this is the one the author
// can act on: a dry run says so instead of the step failing mid-run
// against a rule nobody had seen. `required` catches an absent field;
// this catches a field holding nothing but spaces.
if (!greeting) {
return {
ok: false,
retry: false,
error: 'an oracle with nothing to say would stand there in silence',
}
}
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
if (verify) return { ok: true }
return placeOwned({
runId,
idempotencyKey,
what: 'npc',
body: {
map: place.map,
x: place.x,
y: place.y,
count: howMany.count,
name,
title: String(params.title || '').trim() || undefined,
sex: String(params.sex || '').trim().toLowerCase() === 'female' ? 'female' : undefined,
hue: hue.value,
greeting,
lines: lines.rows,
},
})
},
},
{
id: 'uo.gate.open',
label: 'Open a gate',
description:
'A moongate from one place to another, for a bounded time. The shard closes it when the time is up whether or not the website is ever heard from again, so a run whose engine dies leaves a world that comes back early rather than one stuck open.',
...OWNED_COMMON,
cost: (p) => ({ 'uo.gate.minutes': Number(p.durationMinutes) || 0 }),
params: [
{ ...PLACE_PARAM, description: 'Where the gate stands.' },
{
name: 'destination',
type: 'string',
required: true,
example: 'Felucca/Yew',
source: 'uo.options.landmarks',
description: 'Where it leads.',
},
{
name: 'durationMinutes',
type: 'int',
required: true,
example: 120,
description: `How long it stands, up to ${MAX_GATE_MINUTES} minutes.`,
},
{
name: 'name',
type: 'string',
required: false,
example: 'to the gathering',
description: 'What it is called when a player looks at it.',
},
{ name: 'hue', type: 'int', required: false, example: 1153, description: 'UO colour id.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, retry: false, error: place.error }
const target = await landmarkPoint(params.destination)
if (!target.ok) return { ok: false, retry: false, error: target.error }
const minutes = Number(params.durationMinutes)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_GATE_MINUTES) {
return {
ok: false,
retry: false,
error: `a gate stands 1 to ${MAX_GATE_MINUTES} minutes, and "${params.durationMinutes}" is not that`,
}
}
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
if (verify) return { ok: true }
return placeOwned({
runId,
idempotencyKey,
what: 'gate',
body: {
map: place.map,
x: place.x,
y: place.y,
// A duration, never an absolute time. An absolute deadline computed
// here and honoured there is measured against two clocks, and a shard
// ten minutes fast would collect the gate the instant it opened \u2014
// the same argument protocol 6 made for a lease's `holdMs`.
holdMs: minutes * 60_000,
name: String(params.name || '').trim() || undefined,
hue: hue.value,
target: { map: target.map, x: target.x, y: target.y },
},
})
},
},
{
id: 'uo.decor.place',
label: 'Place decoration',
description:
"Scenery for the venue, from what this shard already calls decoration. Placed immovable so it is still there at teardown, and removed then. Containers are refused: deleting one would delete whatever a player had left inside it.",
...OWNED_COMMON,
cost: (p) => ({ 'uo.decor': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'item',
type: 'string',
required: true,
example: 'Brazier',
source: 'uo.options.decor',
description: "Which item. The list comes from this shard's own decoration files.",
},
{ name: 'count', type: 'int', required: true, example: 6, description: 'How many.' },
{
name: 'hue',
type: 'int',
required: false,
example: 1157,
description: 'UO colour id. Left out, the item keeps its own colour.',
},
{
name: 'spread',
type: 'int',
required: false,
example: 4,
description: `How many tiles to scatter them across, up to ${MAX_SPREAD}.`,
},
{
name: 'name',
type: 'string',
required: false,
example: 'a festival brazier',
description: 'What it is called when a player looks at it.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, retry: false, error: place.error }
const howMany = counted(params.count, MAX_DECOR, 'items')
if (!howMany.ok) return { ok: false, retry: false, error: howMany.error }
const type = String(params.item || '').trim()
if (!type) return { ok: false, retry: false, error: 'pick something to place' }
// Resolved through the atlas rather than passed straight through, which
// does two things at once.
//
// It keeps the verb to the vocabulary this shard's own decoration files
// use \u2014 a tighter boundary than "any item that is not a container", and
// the one the decision actually took.
//
// And it fetches the ITEM ID, which some types cannot do without. Measured
// on ServUO 57.4: `Static` accounts for 5031 of the tree's decoration
// placements under **1992 different graphics**, because for that class the
// graphic IS the identity \u2014 a bare `new Static()` is not the switch or the
// paving stone the author picked, it is whatever the class defaults to.
// 131 of the 313 types carry more than one id (a door has one per facing).
const known = await shardAtlas.getDecorType(type)
if (!known) {
return {
ok: false,
retry: false,
error: `this shard's decoration files never mention "${type}"`,
}
}
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
const spread = optionalInt(params.spread, { max: MAX_SPREAD, name: 'spread' })
if (!spread.ok) return { ok: false, retry: false, error: spread.error }
if (verify) return { ok: true }
return placeOwned({
runId,
idempotencyKey,
what: 'decor',
body: {
map: place.map,
x: place.x,
y: place.y,
count: howMany.count,
type: known.type,
itemId: known.itemId || undefined,
hue: hue.value,
spread: spread.value,
name: String(params.name || '').trim() || undefined,
},
})
},
},
// ── The one-shots (Phase 12b) ────────────────────────────────────────────
//
// Neither owned nor borrowed. Nothing is ledgered because there is nothing
// core could come back for, which is what `reversible: 'none'` says.
{
id: 'uo.item.grant',
label: 'Grant an item',
description:
"Put an item into the hands of everyone who took part in this run. Irreversible: an object in a player's backpack cannot be recalled.",
// **`irreversible`, not `change`.** The world is altered and cannot be put
// back, which is the honest class and the one that makes an author's
// default `on_failure` a pause rather than a retry-then-skip.
risk: 'irreversible',
// Nothing to give back. A `ledger` here would put a row in the cleanup
// ledger that teardown could never resolve — §G's `reversible: 'none'` for
// UO specifically, and the reason is the game's rather than the platform's.
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
cost: (p) => ({ 'uo.rewards': Number(p.amount) || 1 }),
params: [
{
name: 'item',
type: 'string',
required: true,
example: 'gold',
source: 'uo.options.items',
description: 'What to hand out. The list is the shards own allowlist.',
},
{
name: 'amount',
type: 'int',
required: true,
example: 500,
description: `How many each. Stackable items only; ${MAX_GRANT_STACK} at most.`,
},
{
name: 'where',
type: 'string',
required: false,
example: 'backpack',
description: 'backpack or bank. Left out, the backpack.',
},
{
name: 'hue',
type: 'int',
required: false,
example: 1157,
description: 'UO colour id, for a commemorative reward.',
},
{
name: 'name',
type: 'string',
required: false,
example: 'a champions cloak',
description: 'What it is called when a player looks at it.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const item = String(params.item || '').trim()
if (!item) return { ok: false, retry: false, error: 'pick something to grant' }
const known = GRANTABLE.find((g) => g.key === item)
if (!known) {
return { ok: false, retry: false, error: `this shard does not grant "${item}"` }
}
// Its own check rather than `counted()`: that one's refusal reads "place 1
// to N items at a time", which is the spawn verbs' sentence and wrong here
// — nothing is being placed. The bound is the same shape and mirrors
// `Bridge.EventsMaxGrantStack`.
const amount = Number(params.amount)
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GRANT_STACK) {
return {
ok: false,
retry: false,
error: `grant 1 to ${MAX_GRANT_STACK} at a time, and "${params.amount}" is not that`,
}
}
if (!known.stackable && amount > 1) {
return {
ok: false,
retry: false,
error: `${known.label} does not stack, so it can only be granted one at a time`,
}
}
const hue = optionalInt(params.hue, { max: 65535, name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
const where = String(params.where || 'backpack').trim().toLowerCase()
if (where !== 'backpack' && where !== 'bank') {
return { ok: false, retry: false, error: `"${params.where}" is not backpack or bank` }
}
// **The dry run stops here, and it has checked everything it can.** What
// it deliberately does not do is ask the shard who is present: a verify
// that failed because a run has no ledger open would refuse every grant
// authored before its own event ran, which is every grant.
if (verify) return { ok: true }
const result = await uoLinkClient.grantItem({
runId,
item,
amount,
hue: hue.value,
name: params.name ? String(params.name).slice(0, 40) : undefined,
where,
idempotencyKey,
})
// **Retryable, and protocol 6 is the whole reason.** §G called a grant
// un-retryable because a lost acknowledgement and a grant that never
// applied were the same event — exactly the argument that made
// `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey`
// closes that: a repeat is answered by the original reply, so a retried
// grant cannot be one winner receiving two.
if (!result.ok) return sidecarFailure(result, 'grant')
const granted = Number(result.data?.granted) || 0
const missed = (result.data && result.data.missed) || []
// A grant that reached nobody is a SUCCESS, and the distinction is the
// shard's: a run it was never told to count is a 404 above, while a run
// whose ledger is open and empty answers 200 with `granted: 0`. An event
// nobody attended still happened, and retrying against the same empty
// ledger would pause a run for ever.
return {
ok: true,
detail: { granted, missed: missed.length, ...(missed.length ? { why: missed.slice(0, 10) } : {}) },
}
},
},
{
id: 'uo.world.save',
label: 'Save the world',
description:
'Ask the shard to write a world save. Useful as a phase boundary — the point after which what the event has done so far survives a crash.',
// Nothing is created and nothing is altered; the world is written to disk.
// `inspect` would be a lie (it stops the world for a moment) and `change`
// is what that is.
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [],
async perform({ idempotencyKey, verify }) {
if (verify) return { ok: true }
const result = await uoLinkClient.saveWorld({ idempotencyKey })
// 429 is the shard's save rate limit, and it is the one refusal on this
// plane that waiting fixes: the same request succeeds once the interval
// passes. It is not in `PERMANENT_STATUSES`, so `sidecarFailure`
// classifies it retry without needing an arm of its own — which is what
// makes a phase boundary retried rather than abandoned.
if (!result.ok) return sidecarFailure(result, 'world save')
// What actually happened rides `world.save.before`/`after` on the event
// stream. This step reports only that the save was started, because that
// is the only thing the reply knows.
return { ok: true, detail: { started: true } }
},
},
]
/**
* Which of these does the shard still have? — answered from the boot stamp.
*
* Shared by both keyed verbs because the answer has the same shape for both:
* shard memory, lost on restart. See rule 3 in the header for why this needs no
* round trip and why it must not simply answer "all gone".
*
* **A row with no stamp is reported IN FORCE.** It was written by a build before
* the stamp existed, or by a `perform()` whose config read hiccuped, and "I do not
* know" must never be read as "it is gone" — core orphans exactly what this omits,
* and an orphaned row is one teardown will never try to take back.
*/
async function reconcileByBootId({ resources }) {
const bootId = await currentBootId()
// Nothing has connected since this process came up, so there is no current boot
// to compare against. Declining to answer leaves core believing its ledger.
if (!bootId) return { ok: false, error: 'the shard has not identified itself since boot' }
const inForce = resources
.filter((r) => {
const stamped = r.payload && r.payload.bootId
return !stamped || stamped === bootId
})
.map((r) => r.ref)
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
}
// ── The targeted leases (protocol 7 part b, Phase 12b) ─────────────────────
//
// What an event BORROWS. Five keys over two planes, and every one of them is
// targeted — a property lives on a particular object and a seasonal status on a
// particular event, so the lease id names the capability and the target names
// the thing.
//
// **The module still never writes a lease and never bounds one.** An author puts
// `core.lease` in a step naming a lease, a target, a value and a number of
// minutes; core reads the baseline, reserves `<lease id>#<target>` against the
// two-events-one-target index, applies the value with its deadline and restores
// it at teardown. What is here is the four callables, exactly as the config
// lease has had since 11b — with the target now handed to each of them.
/** Twelve hours, the same ceiling the config lease carries. */
const MAX_PROP_LEASE_MS = 12 * 60 * 60 * 1000
/**
* One row of the shard's lease frame, for one key and one target.
*
* A targeted key has no single `current`, so the shard is asked about the one
* that matters rather than walked. Null when the frame could not be read at all,
* which the callables turn into a refusal rather than a value.
*/
async function leaseRowFor(key, target) {
const result = await uoLinkClient.getLeases({ key, target })
if (!result.ok) return null
const rows = (result.data && result.data.leases) || []
return rows.find((r) => r && r.key === key) || null
}
/**
* Whether the shard is still holding this key on this target.
*
* **Read from `holds`, not from a row's `held` flag**, and the difference only
* appears on a targeted key: `held` is filled in against the target the frame
* was narrowed to, so it answers for the row that was asked about — while
* `holds` is every hold the shard actually has. They agree here; `holds` is used
* because it is the one that stays true if the frame is ever asked without a
* target, and because it is the list a reconcile after a long outage wants.
*/
async function leaseHeld(key, target) {
const result = await uoLinkClient.getLeases({ key, target })
if (!result.ok) return null
const holds = (result.data && result.data.holds) || []
return holds.some((h) => h && h.key === key && String(h.target || '') === String(target || ''))
}
/**
* The four callables every targeted lease shares.
*
* They differ only in which key they name, so they are built rather than
* repeated: five copies of this would be five chances for one of them to forget
* the drift check, which is the one thing §F says a lease must not be allowed to
* skip.
*/
function targetedLease({ id, key, label, description, type, min, max, values, targetLabel, source }) {
return {
id,
label,
description,
type,
...(min === undefined ? {} : { min }),
...(max === undefined ? {} : { max }),
...(values ? { values } : {}),
maxDurationMs: MAX_PROP_LEASE_MS,
target: {
label: targetLabel,
source,
example: source === 'uo.options.spawners' ? '003f11b8-9bfa-4587-991e-ca263004efe6' : 'Fellowship',
},
async read({ target } = {}) {
const row = await leaseRowFor(key, target)
if (!row) return { ok: false, error: 'the shard did not report its lease catalog' }
// **`unreadable` is a refusal, and `current` missing is too.** A spawner
// that has been deleted answers with a reason rather than a value, and
// taking the lease anyway would record a fictional baseline and later
// write it onto whatever next held that id.
if (row.unreadable) return { ok: false, error: row.unreadable }
if (row.current === undefined || row.current === null) {
return { ok: false, error: `the shard could not read ${label} for that target` }
}
return { ok: true, value: row.current }
},
async apply(value, until, { target } = {}) {
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,
target,
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, target } = {}) {
const result = await uoLinkClient.releaseLease({ key, target, expected, baseline })
if (result.ok && result.data && result.data.kind === 'lease.drifted') {
return { ok: false, drifted: true, current: result.data.current }
}
// **A target that no longer exists is a successful release**, not a
// failure. Somebody deleted the spawner mid-run: there is nothing to
// restore and nothing owed, and reporting it as failed would leave a
// ledger row unresolved for ever over an object that is gone. It is 12a's
// `gone` in the lease plane's vocabulary.
if (result.ok && result.data && result.data.targetGone === true) return { ok: true }
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease release') }
return { ok: true }
},
async inForce({ target } = {}) {
const held = await leaseHeld(key, target)
if (held === null) return { ok: false, error: 'the shard did not report its lease catalog' }
return { ok: true, held }
},
}
}
const SPAWNER_TARGET = {
targetLabel: 'Which spawner',
source: 'uo.options.spawners',
}
/** Twenty-four hours in seconds, the bound on a respawn window. */
const MAX_SPAWN_DELAY_SEC = 86400
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 }
},
},
// **`MaxCount`, not `Amount`.** EVENTS_PLAN.md named `Spawner.Amount`; there
// is no such property on ServUO 57.4. The count is `MaxCount` on BOTH
// `Spawner` and `XmlSpawner`, which share all four names here — a fact worth
// knowing rather than a convenience, because the shard's own `Spawns/*.xml`
// load as XmlSpawners while `[add spawner` makes the native one, and a catalog
// that named only one of them would work until the day it did not.
targetedLease({
id: 'uo.spawner.maxcount',
key: 'Spawner.MaxCount',
label: 'Spawner: how many at once',
description:
'How many creatures one spawner keeps alive. Takes effect on its next tick, so an invasion turns a spawner up for its duration and it winds back down at teardown.',
type: 'int',
min: 0,
max: 100,
...SPAWNER_TARGET,
}),
// **Seconds, and the shard converts.** The property is a `TimeSpan` and the
// lease type vocabulary is int/float/bool/string with no duration in it. The
// unit is seconds rather than minutes because the spawn files' own
// `DelayInSec` flag proves both are in use on a real tree, and a unit that
// cannot express five seconds cannot express this shard's own data.
targetedLease({
id: 'uo.spawner.mindelay',
key: 'Spawner.MinDelay',
label: 'Spawner: shortest respawn wait',
description: 'The shortest a spawner waits before replacing what was killed, in seconds.',
type: 'int',
min: 0,
max: MAX_SPAWN_DELAY_SEC,
...SPAWNER_TARGET,
}),
targetedLease({
id: 'uo.spawner.maxdelay',
key: 'Spawner.MaxDelay',
label: 'Spawner: longest respawn wait',
description: 'The longest a spawner waits before replacing what was killed, in seconds.',
type: 'int',
min: 0,
max: MAX_SPAWN_DELAY_SEC,
...SPAWNER_TARGET,
}),
targetedLease({
id: 'uo.spawner.running',
key: 'Spawner.Running',
label: 'Spawner: running',
description:
'Whether a spawner runs at all. Switching one off for the length of an event is how a venue is cleared without deleting anything.',
type: 'bool',
...SPAWNER_TARGET,
}),
// **A three-value enum over eight events, not a nine-way choice.** §G called
// `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and had it
// backwards: `EventStatus` has three values and it is `EventType` that has
// nine entries.
//
// Eight rather than nine because `TreasuresOfTokuno` is excluded on the shard:
// `IsActive()` special-cases it and reads `TreasuresOfTokuno.DropEra` instead
// of `Status`, so leasing it would write a field nothing consults — the write
// succeeds, the value reads back, a compare-and-set restore passes, and the
// capability does nothing at all. That is precisely the failure §N10's
// self-check exists for, and it is the one instance no runtime probe can
// catch, so it is caught by reading the source.
//
// And it is not a small toggle: `OnStatusChange()` calls a `CheckEnabled()`
// that generates or removes world content for six of the eight. It is safe —
// ServUO does exactly this to itself from a staff gump — but an author
// scheduling one should know it is more than a flag.
targetedLease({
id: 'uo.seasonal.status',
key: 'Seasonal.Status',
label: 'Seasonal event status',
description:
"Switch one of ServUO's own seasonal events on or off for the length of a run. Six of the eight generate or remove world content when they change, so this is a bigger lever than it looks.",
type: 'string',
values: ['Inactive', 'Active', 'Seasonal'],
targetLabel: 'Which seasonal event',
source: 'uo.options.seasonal',
}),
]
// ── Option sources ─────────────────────────────────────────────────────────
//
// Answered from the spawn atlas, which is derived from the operator's own ServUO
// tree on every boot and stored — so these resolve with the shard down, which is
// the property that makes them safe to put behind an authoring form.
//
// **Wave 1's three verbs use none of them.** They ship here rather than with
// their consumers in Phase 12 (org lead, 2026-09-04) because they cost nothing
// new, and Phase 12 is a five-repo protocol bump that should not also be carrying
// its first atlas plumbing. `/admin/events/catalog/options/:sourceId` exercises
// them today.
//
// **A place is named `facet/name`, not `name`.** Two facets both have a Britain,
// and a value that can name two different places is a value a Phase 12 step
// cannot act on. The author reads the label and the group; the stored value is
// unambiguous.
/** Bound one source's answer, and say so when the atlas is bigger than the bound. */
function bounded(rows, sourceId) {
if (rows.length <= MAX_OPTIONS) return rows
log.warn('option source truncated — the atlas is larger than the dropdown bound', {
source: sourceId,
available: rows.length,
served: MAX_OPTIONS,
})
return rows.slice(0, MAX_OPTIONS)
}
const OPTION_SOURCES = [
{
id: 'uo.options.regions',
label: 'Regions',
description: "Named regions from the shard's own map definitions, by facet.",
async resolve() {
const rows = await shardAtlas.listRegions()
return bounded(rows, 'uo.options.regions').map((r) => ({
value: `${r.facet}/${r.name}`,
label: r.name,
group: r.facet,
}))
},
},
{
id: 'uo.options.landmarks',
label: 'Landmarks',
description: 'Named points of interest — towns, dungeons, moongates — by facet.',
async resolve() {
const rows = await shardAtlas.listLandmarks()
return bounded(rows, 'uo.options.landmarks').map((r) => ({
// **`facet/group/name`, because `facet/name` does not name one place.**
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name`
// pairs: `Trammel/Entrance` is 23 different dungeons, and `landmarkPoint`
// resolves with `.find()`, so 22 of them were unreachable — an author who
// picked "Entrance — Destard" got Blighted Grove, with a successful run
// and no warning. The group was already the disambiguator; it was shown
// to the eye and left out of the value. All 558 are distinct with it.
value: landmarkValue(r),
label: r.name,
// The atlas's own grouping where it has one, the facet otherwise — so a
// shard whose landmark file carries no groups still gets a usable
// dropdown rather than one flat list of several hundred names.
group: r.group || r.facet,
}))
},
},
{
id: 'uo.options.creatures',
label: 'Creatures',
description: 'Creature types the shard actually spawns, from the spawn atlas.',
async resolve() {
// **The value is the ServUO TYPE NAME, not the atlas slug** (Phase 12a).
//
// Wave 1 declared this source before anything consumed it and used the
// slug, which is unique and stable and cannot be acted on: the shard
// constructs a creature from a class name, and `orccaptain` is not one.
// The atlas's `name` IS the type token, preserved verbatim from the spawn
// files (`displayName` picks the best-attested spelling of the raw token),
// so no lookup table is needed \u2014 only the decision to stop throwing the
// usable half away.
//
// Safe to change because Phase 12a is this source's first consumer; the
// file said so when it shipped.
const { creatures } = await shardAtlas.searchCreatures({ limit: MAX_OPTIONS })
return creatures.map((c) => ({ value: c.name, label: c.name }))
},
},
{
id: 'uo.options.decor',
label: 'Decoration',
description: "Item types this shard already uses as scenery, most-used first.",
async resolve() {
// From `Data/Decoration/**/*.cfg` at atlas-import time, so this is the
// operator's own decoration vocabulary rather than a list curated by us \u2014
// and, like every source here, it resolves with the shard down.
const rows = await shardAtlas.listDecorTypes()
return bounded(rows, 'uo.options.decor').map((r) => ({ value: r.type, label: r.type }))
},
},
{
id: 'uo.options.spawners',
label: 'Spawners',
// **The first searchable source, and the first one that had to be.** This
// tree has 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list
// would drop two thirds of the world and say nothing about which two thirds
// — the failure Phase 12a named for decoration, arriving for real. Core
// passes `q` to every source and requires it of none; this one reads it.
searchable: true,
description: "Spawners from the shard's own spawn files, searched by name, region or landmark.",
async resolve({ q } = {}) {
const rows = await shardAtlas.listSpawners({ q, limit: SPAWNER_OPTIONS })
return rows.map((r) => ({
// The `UniqueId`. It is the only name for one particular spawner that
// exists off the shard — a serial is assigned when the world is built —
// and it is what the plugin resolves a target by.
value: r.uniqueId,
label: r.name || r.uniqueId,
// Where it is, because two spawners can share a name and an author
// choosing between them is choosing a place.
group: r.region || r.landmark || r.facet,
}))
},
},
{
id: 'uo.options.seasonal',
label: 'Seasonal events',
description: "ServUO's own seasonal events, the ones whose status actually does something.",
async resolve() {
// **Held here rather than read from the shard, and eight rather than
// nine.** `EventType` is a compile-time enum in ServUO, so it does not
// change under a running shard and there is nothing to import; and
// `TreasuresOfTokuno` is left out because its `IsActive()` reads its own
// era rather than this status, so leasing it would be a capability that
// lies. The plugin refuses it independently.
return SEASONAL_EVENTS.map((name) => ({ value: name, label: SEASONAL_LABELS[name] || name }))
},
},
{
id: 'uo.options.items',
label: 'Grantable items',
description: 'What this shard is willing to hand out as a reward.',
async resolve() {
// Mirrored rather than read live, exactly like the lease bounds: this copy
// is what makes a bad value a refusal on a FORM, and the shard's own copy
// is what is true when this one is wrong. Reading it live would put an
// authoring dropdown behind the shard being up, which §F specifically
// says a source must not do.
return GRANTABLE.map((g) => ({
value: g.key,
label: g.stackable ? `${g.label} (stacks)` : g.label,
}))
},
},
]
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
// that could drift from them.
BUDGET_MS,
MAX_BROADCAST_LEN,
MAX_CRIER_LINES,
MAX_CRIER_LINE_LEN,
MAX_NEWS_TITLE,
MAX_NEWS_BODY,
MAX_OPTIONS,
MAX_AREA_RADIUS,
MAX_CREATURES,
MAX_BOSSES,
MAX_NPCS,
MAX_DECOR,
MAX_SPREAD,
MAX_BOSS_MULTIPLIER,
MAX_ORACLE_LINES,
MAX_GATE_MINUTES,
OWNED_KIND,
oracleLines,
revertOwned,
reconcileOwned,
MAX_LEASE_MS,
MAX_PROP_LEASE_MS,
MAX_SPAWN_DELAY_SEC,
MAX_GRANT_STACK,
GRANTABLE,
SEASONAL_EVENTS,
SPAWNER_OPTIONS,
PERMANENT_STATUSES,
webUserId,
landmarkPoint,
sidecarReason,
resourceId,
crierLines,
reconcileByBootId,
}