feat(events): what an author borrows, and two one-shots (Phase 12b)
Five targeted leases over two planes, the item grant, the world save, and the atlas work the spawner dropdown needed. FIVE LEASES, ONE FACTORY `uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and `uo.seasonal.status`. The four callables differ only in which key they name, so they are built rather than repeated: five copies 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. It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a real tree, and a unit that cannot express five seconds cannot express this shard's own data. The seasonal lease is a THREE-value enum over EIGHT events. §G called `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 -- `IsActive()` reads its own `DropEra` rather than `Status`, so leasing it would apply cleanly, read back, restore cleanly and do nothing at all. Two behaviours worth the review. `inForce()` reads the frame's `holds` rather than a row's `held` flag, because a catalog walk can enumerate the keys but never the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL restore: there is nothing to give back, and reporting it failed would leave a ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the lease plane's vocabulary. THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST Core has the participants in `event_run_participants`, but a module cannot read core's tables -- so the alternative was a new core surface handing them over. Not needed: the shard has held the run's ledger since it opened, keyed by the same serials core stores as `member_key`. And the grant is RETRYABLE. §G called it un-retryable because a lost acknowledgement and a grant that never applied were the same event, which is exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9. Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than grants: 500 gold to forty people and a candle to forty people are not the same imposition. THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a line citing a committed artifact -- there is no committed artifact, as `spawnAtlasSource.js` says in its own header. It is the ONLY name for one particular spawner that exists off the shard, so a property lease could not have had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read. `uo.options.spawners` is the first searchable source and the first that had to be: 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. ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as `X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from Phase 11a onward every sidecar call on a real deployment would have been refused -- the whole event plane dead, loudly, for a reason nobody would look here for. 11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two places this repo declares it. It survived both because both live walks set the column by hand while standing the rig up, which is exactly what makes a migration nobody runs invisible. All three sites go to 7. The test that guards them is worth understanding before trusting it: `schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER -- a real check they once failed -- but all three being equally stale passes it, and nothing in this repo can anchor it to the wire. Recorded in the model's own header so the next reader knows. CHECKS `npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals` clean; the client builds and its 42 tests pass. `check:swagger` reports the fragment stale -- it is ALREADY stale on `edge` (verified by stashing this branch's changes and re-running) and this phase adds no route, so it is left alone rather than regenerated inside an unrelated change. Two bugs the new tests caught in this branch's own code before it left: `counted()` returns `.count` and the grant read `.value`, so every grant went out with `amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s `ok` was ignored, so a bad hue passed silently instead of refusing. Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -259,6 +259,13 @@ const BUDGETS = [
|
||||
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) ────────────
|
||||
@@ -510,6 +517,79 @@ async function reconcileOwned({ runId, resources }) {
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1451,6 +1531,178 @@ const ACTIONS = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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 shard’s 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 champion’s 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 } }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -1507,6 +1759,142 @@ async function leaseRow(key) {
|
||||
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',
|
||||
@@ -1590,6 +1978,85 @@ const LEASES = [
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -1684,6 +2151,60 @@ const OPTION_SOURCES = [
|
||||
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 = {
|
||||
@@ -1715,6 +2236,12 @@ module.exports = {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user