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',
|
unit: 'minutes',
|
||||||
description: 'Total minutes of temporary gate this run may open, across every gate.',
|
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) ────────────
|
// ── 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 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 = {
|
const OWNED_COMMON = {
|
||||||
// Something appears in the world where there was nothing. §K's default-off
|
// 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
|
// 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
|
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 = [
|
const LEASES = [
|
||||||
{
|
{
|
||||||
id: 'uo.playercaps.skillcap',
|
id: 'uo.playercaps.skillcap',
|
||||||
@@ -1590,6 +1978,85 @@ const LEASES = [
|
|||||||
return { ok: true, held: row.held === true }
|
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 ─────────────────────────────────────────────────────────
|
// ── Option sources ─────────────────────────────────────────────────────────
|
||||||
@@ -1684,6 +2151,60 @@ const OPTION_SOURCES = [
|
|||||||
return bounded(rows, 'uo.options.decor').map((r) => ({ value: r.type, label: r.type }))
|
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 = {
|
module.exports = {
|
||||||
@@ -1715,6 +2236,12 @@ module.exports = {
|
|||||||
revertOwned,
|
revertOwned,
|
||||||
reconcileOwned,
|
reconcileOwned,
|
||||||
MAX_LEASE_MS,
|
MAX_LEASE_MS,
|
||||||
|
MAX_PROP_LEASE_MS,
|
||||||
|
MAX_SPAWN_DELAY_SEC,
|
||||||
|
MAX_GRANT_STACK,
|
||||||
|
GRANTABLE,
|
||||||
|
SEASONAL_EVENTS,
|
||||||
|
SPAWNER_OPTIONS,
|
||||||
PERMANENT_STATUSES,
|
PERMANENT_STATUSES,
|
||||||
webUserId,
|
webUserId,
|
||||||
landmarkPoint,
|
landmarkPoint,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
|||||||
base_url VARCHAR(255) NULL,
|
base_url VARCHAR(255) NULL,
|
||||||
ws_url VARCHAR(255) NULL,
|
ws_url VARCHAR(255) NULL,
|
||||||
auth_token_enc TEXT NULL,
|
auth_token_enc TEXT NULL,
|
||||||
protocol INT NOT NULL DEFAULT 5,
|
protocol INT NOT NULL DEFAULT 7,
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
status_detail VARCHAR(500) NULL,
|
status_detail VARCHAR(500) NULL,
|
||||||
@@ -485,6 +485,13 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
facet VARCHAR(40) NOT NULL,
|
facet VARCHAR(40) NOT NULL,
|
||||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||||
|
-- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner
|
||||||
|
-- that exists OFF the shard. A property lease is targeted by it, because a
|
||||||
|
-- serial is assigned when the world is built and nothing here could know one --
|
||||||
|
-- so without this column the lease's target field could have no dropdown at
|
||||||
|
-- all. NULLable: a shard's own spawners, added in-world rather than from the
|
||||||
|
-- spawn files, carry none, and they are addressed by serial instead.
|
||||||
|
unique_id VARCHAR(64) NULL,
|
||||||
x INT NOT NULL,
|
x INT NOT NULL,
|
||||||
y INT NOT NULL,
|
y INT NOT NULL,
|
||||||
width INT NOT NULL DEFAULT 0,
|
width INT NOT NULL DEFAULT 0,
|
||||||
@@ -500,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
|||||||
landmark VARCHAR(120) NULL,
|
landmark VARCHAR(120) NULL,
|
||||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||||
INDEX idx_shard_spawn_points_facet (facet),
|
INDEX idx_shard_spawn_points_facet (facet),
|
||||||
INDEX idx_shard_spawn_points_label (label)
|
INDEX idx_shard_spawn_points_label (label),
|
||||||
|
-- The spawner target's dropdown searches by name, and 6,707 rows is more than
|
||||||
|
-- a dropdown holds, so the search is the read rather than a filter over one.
|
||||||
|
INDEX idx_shard_spawn_points_name (name)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- The many-to-many between the two above: one spawner commonly carries several
|
-- The many-to-many between the two above: one spawner commonly carries several
|
||||||
@@ -806,3 +816,24 @@ UPDATE uo_link_config SET protocol = 5
|
|||||||
WHERE id = 1 AND protocol < 5
|
WHERE id = 1 AND protocol < 5
|
||||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
||||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
||||||
|
|
||||||
|
-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged
|
||||||
|
-- code rather than ordinary Phase 12b work.
|
||||||
|
--
|
||||||
|
-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved
|
||||||
|
-- this. `uoLinkClient` sends `X-UOLink-Version: <this column>` on every call and
|
||||||
|
-- the sidecar answers an exact mismatch with a 409, so a deployment that installed
|
||||||
|
-- this module at any point since Phase 10 would have had EVERY sidecar call
|
||||||
|
-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but
|
||||||
|
-- for a reason nobody would look here for.
|
||||||
|
--
|
||||||
|
-- It survived two phases because both live walks set the column by hand while
|
||||||
|
-- standing the rig up, which is exactly the shape of a migration nobody runs.
|
||||||
|
-- One block carries an install the whole way rather than one per missed version:
|
||||||
|
-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote
|
||||||
|
-- `< n` rather than `= n-1`.
|
||||||
|
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7;
|
||||||
|
UPDATE uo_link_config SET protocol = 7
|
||||||
|
WHERE id = 1 AND protocol < 7
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated');
|
||||||
|
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1');
|
||||||
|
|||||||
@@ -121,13 +121,14 @@ async function replaceAtlas(atlas, art = {}) {
|
|||||||
counts.points = await insertBatched(
|
counts.points = await insertBatched(
|
||||||
conn,
|
conn,
|
||||||
'INSERT INTO shard_spawn_points ' +
|
'INSERT INTO shard_spawn_points ' +
|
||||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
'(id, facet, name, unique_id, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
atlas.points.map((p, i) => [
|
atlas.points.map((p, i) => [
|
||||||
i + 1,
|
i + 1,
|
||||||
p.facet,
|
p.facet,
|
||||||
p.name,
|
p.name,
|
||||||
|
p.uniqueId || null,
|
||||||
p.x,
|
p.x,
|
||||||
p.y,
|
p.y,
|
||||||
p.width ?? 0,
|
p.width ?? 0,
|
||||||
@@ -391,6 +392,41 @@ function listDecorTypes({ q = '' } = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawners an author can name, searched by name and bounded (Phase 12b).
|
||||||
|
*
|
||||||
|
* **A search rather than a list, and the numbers are why.** This tree has 6,707
|
||||||
|
* spawn points against a 2,000-entry dropdown bound, so a flat read would drop
|
||||||
|
* two thirds of the world and say nothing about which two thirds — the failure
|
||||||
|
* Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew
|
||||||
|
* a `q` for this.
|
||||||
|
*
|
||||||
|
* Only rows with a `unique_id` are offered: that is the only name for a spawner
|
||||||
|
* that exists off the shard, and a row without one cannot be targeted from a
|
||||||
|
* form however it is labelled. A shard's own in-world spawners have none and are
|
||||||
|
* addressed by serial, which an author types rather than picks.
|
||||||
|
*
|
||||||
|
* Ordered by `max_count DESC` so the spawners worth an event's attention come
|
||||||
|
* first, with a stable alphabetical tiebreak for a form somebody scrolls.
|
||||||
|
*/
|
||||||
|
function listSpawners({ q = '', limit = 200 } = {}) {
|
||||||
|
const where = ['unique_id IS NOT NULL', "unique_id <> ''"]
|
||||||
|
const params = []
|
||||||
|
if (q) {
|
||||||
|
where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)')
|
||||||
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`)
|
||||||
|
}
|
||||||
|
params.push(Number(limit) || 200)
|
||||||
|
return query(
|
||||||
|
`SELECT unique_id, name, facet, region, landmark, max_count
|
||||||
|
FROM shard_spawn_points
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY max_count DESC, name ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** One decoration type, or nothing when this shard's files never name it. */
|
/** One decoration type, or nothing when this shard's files never name it. */
|
||||||
async function getDecorType(type) {
|
async function getDecorType(type) {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
@@ -432,6 +468,7 @@ module.exports = {
|
|||||||
listRegions,
|
listRegions,
|
||||||
listLandmarks,
|
listLandmarks,
|
||||||
listDecorTypes,
|
listDecorTypes,
|
||||||
|
listSpawners,
|
||||||
getDecorType,
|
getDecorType,
|
||||||
listChampions,
|
listChampions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,27 @@ async function listDecorTypes(opts = {}) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawners an author can name, searched (Phase 12b).
|
||||||
|
*
|
||||||
|
* The value is the `UniqueId` because that is what the shard resolves a target
|
||||||
|
* by; the label is the spawner's own name, which is what an author recognises
|
||||||
|
* ("fel bulbous putrification" is a place they know). A row with no name still
|
||||||
|
* answers, labelled by its id, rather than being dropped: a nameless spawner is
|
||||||
|
* still a spawner somebody may need to turn down.
|
||||||
|
*/
|
||||||
|
async function listSpawners(opts = {}) {
|
||||||
|
const rows = await db.listSpawners(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
uniqueId: r.unique_id,
|
||||||
|
name: r.name || null,
|
||||||
|
facet: r.facet,
|
||||||
|
region: r.region || null,
|
||||||
|
landmark: r.landmark || null,
|
||||||
|
maxCount: Number(r.max_count) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One decoration type, or null.
|
* One decoration type, or null.
|
||||||
*
|
*
|
||||||
@@ -518,6 +539,7 @@ module.exports = {
|
|||||||
listRegions,
|
listRegions,
|
||||||
listLandmarks,
|
listLandmarks,
|
||||||
listDecorTypes,
|
listDecorTypes,
|
||||||
|
listSpawners,
|
||||||
getDecorType,
|
getDecorType,
|
||||||
listChampions,
|
listChampions,
|
||||||
listFacets,
|
listFacets,
|
||||||
|
|||||||
@@ -11,16 +11,28 @@ const { secretBox } = require('../../core')
|
|||||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||||
//
|
//
|
||||||
// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
|
// This says 7 because this build speaks protocol 7: the idempotency key and the
|
||||||
// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
|
// participation ledger (6), and the world verbs plus the targeted lease planes (7).
|
||||||
//
|
//
|
||||||
// It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
|
// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this
|
||||||
// this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
|
// constant was introduced to fix. A FRESH install pinned 3, the sidecar answered
|
||||||
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
|
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
|
||||||
// from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
|
// from its shard until an admin edited the number by hand in Admin → Shard.
|
||||||
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
|
//
|
||||||
// matching cutover in db/schema.sql.
|
// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in
|
||||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
|
// `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call
|
||||||
|
// on a real deployment would have been refused. Both live walks set the column by hand
|
||||||
|
// while standing the rig up, which is exactly what makes a migration nobody runs
|
||||||
|
// invisible. Phase 12b carries all three to 7.
|
||||||
|
//
|
||||||
|
// **Nothing in this repo can check this against the wire**, and that is worth knowing
|
||||||
|
// before trusting the test that guards it: `schemaFragment.test.js` asserts the three
|
||||||
|
// declarations agree WITH EACH OTHER, which is a real check — they drifted apart once —
|
||||||
|
// but all three being equally stale passes it. The wire's version lives in `link`
|
||||||
|
// (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that
|
||||||
|
// actually pairs them is the installer's bundle check, at deploy time. So bumping this in
|
||||||
|
// the same change as the emitters is still the discipline, and no test here replaces it.
|
||||||
|
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 7
|
||||||
|
|
||||||
function toSafe(row) {
|
function toSafe(row) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
|||||||
@@ -67,11 +67,13 @@ test('registers exactly what module.json declares', () => {
|
|||||||
'uo.creature.spawn',
|
'uo.creature.spawn',
|
||||||
'uo.decor.place',
|
'uo.decor.place',
|
||||||
'uo.gate.open',
|
'uo.gate.open',
|
||||||
|
'uo.item.grant',
|
||||||
'uo.news.post',
|
'uo.news.post',
|
||||||
'uo.npc.place',
|
'uo.npc.place',
|
||||||
'uo.participation.collect',
|
'uo.participation.collect',
|
||||||
'uo.participation.open',
|
'uo.participation.open',
|
||||||
'uo.towncrier.post',
|
'uo.towncrier.post',
|
||||||
|
'uo.world.save',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
|
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
|
||||||
@@ -85,18 +87,45 @@ test('registers exactly what module.json declares', () => {
|
|||||||
'uo.npcs',
|
'uo.npcs',
|
||||||
'uo.decor',
|
'uo.decor',
|
||||||
'uo.gate.minutes',
|
'uo.gate.minutes',
|
||||||
|
'uo.rewards',
|
||||||
])
|
])
|
||||||
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
||||||
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
||||||
// on any of the rest applies cleanly and does nothing.
|
// on any of the rest applies cleanly and does nothing.
|
||||||
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
|
// Phase 12b adds five TARGETED leases beside it -- a key that names a capability
|
||||||
|
// over many things, with the target supplied per step. Four spawner properties
|
||||||
|
// (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property
|
||||||
|
// on ServUO 57.4) and the seasonal status, which is a three-value enum over eight
|
||||||
|
// events rather than the nine-value one section G described.
|
||||||
|
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [
|
||||||
|
'uo.playercaps.skillcap',
|
||||||
|
'uo.spawner.maxcount',
|
||||||
|
'uo.spawner.mindelay',
|
||||||
|
'uo.spawner.maxdelay',
|
||||||
|
'uo.spawner.running',
|
||||||
|
'uo.seasonal.status',
|
||||||
|
])
|
||||||
|
// Only the targeted ones declare a target, and every one of them names a source:
|
||||||
|
// a target field with no list behind it is the free-text box the option-source
|
||||||
|
// contract exists to replace.
|
||||||
|
for (const lease of api.record.eventLeases) {
|
||||||
|
if (lease.id === 'uo.playercaps.skillcap') {
|
||||||
|
assert.strictEqual(lease.target, undefined, 'a config lease has no target')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`)
|
||||||
|
assert.ok(lease.target.source, `${lease.id} has no target source`)
|
||||||
|
}
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(
|
||||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||||
[
|
[
|
||||||
'uo.options.creatures',
|
'uo.options.creatures',
|
||||||
'uo.options.decor',
|
'uo.options.decor',
|
||||||
|
'uo.options.items',
|
||||||
'uo.options.landmarks',
|
'uo.options.landmarks',
|
||||||
'uo.options.regions',
|
'uo.options.regions',
|
||||||
|
'uo.options.seasonal',
|
||||||
|
'uo.options.spawners',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert.ok(api.record.streams.length > 0)
|
assert.ok(api.record.streams.length > 0)
|
||||||
|
|||||||
@@ -145,9 +145,14 @@ test('parsePoints: reads the kept fields and drops the rest', () => {
|
|||||||
assert.equal(covetous.minDelay, 300)
|
assert.equal(covetous.minDelay, 300)
|
||||||
assert.equal(covetous.maxDelay, 600)
|
assert.equal(covetous.maxDelay, 600)
|
||||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
|
||||||
// under 1 MB.
|
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
|
||||||
assert.equal(covetous.uniqueId, undefined)
|
// live spawner — so it is the only name for one particular spawner that exists
|
||||||
|
// off the shard, and a property lease targets by it. A serial cannot do that
|
||||||
|
// job: serials are assigned when the world is built and nothing here knows one.
|
||||||
|
assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370')
|
||||||
|
// The rest of the dropped fields still are. Triggering, refractory windows,
|
||||||
|
// proximity and sounds are what the site has no use for.
|
||||||
assert.equal(covetous.proximityTriggerSound, undefined)
|
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -159,12 +159,24 @@ test('the declarations satisfy the shape core validates them with', () => {
|
|||||||
|
|
||||||
test('every dimension a cost names is one this module declares', () => {
|
test('every dimension a cost names is one this module declares', () => {
|
||||||
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
||||||
// Phase 12a. All six are the MODULE's (org lead, 2026-09-07): core meters what
|
// Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead,
|
||||||
// a module declares and holds no UO knowledge, so a `uo.` dimension core knew
|
// 2026-09-07): core meters what a module declares and holds no UO knowledge, so
|
||||||
// about would be a leak of this game into the engine.
|
// a `uo.` dimension core knew about would be a leak of this game into the engine.
|
||||||
|
//
|
||||||
|
// `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty
|
||||||
|
// people and one giving a candle to forty people are not the same imposition, and
|
||||||
|
// a count of grants would price them identically.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[...declared],
|
[...declared],
|
||||||
['uo.broadcasts', 'uo.creatures', 'uo.bosses', 'uo.npcs', 'uo.decor', 'uo.gate.minutes'],
|
[
|
||||||
|
'uo.broadcasts',
|
||||||
|
'uo.creatures',
|
||||||
|
'uo.bosses',
|
||||||
|
'uo.npcs',
|
||||||
|
'uo.decor',
|
||||||
|
'uo.gate.minutes',
|
||||||
|
'uo.rewards',
|
||||||
|
],
|
||||||
)
|
)
|
||||||
for (const b of actions.BUDGETS) {
|
for (const b of actions.BUDGETS) {
|
||||||
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
||||||
|
|||||||
349
server/test/uoEventBorrowed.test.js
Normal file
349
server/test/uoEventBorrowed.test.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
// module-uo's half of protocol 7 part b (EVENTS_PLAN.md Phase 12b).
|
||||||
|
//
|
||||||
|
// What an event BORROWS — five targeted leases over two planes — and the two
|
||||||
|
// one-shots that are neither borrowed nor owned.
|
||||||
|
//
|
||||||
|
// The tests below are the places where the obvious implementation is subtly the
|
||||||
|
// wrong one and nothing would fail if it were written the other way:
|
||||||
|
//
|
||||||
|
// • every callable of a targeted lease must PASS THE TARGET ON. A read that
|
||||||
|
// dropped it would answer about the wrong spawner, and a restore that
|
||||||
|
// dropped it would write a baseline onto one
|
||||||
|
// • a target the shard can no longer read is a REFUSAL at apply time, never a
|
||||||
|
// value: taking the lease anyway records a fictional baseline and later
|
||||||
|
// writes it onto whatever next holds that id
|
||||||
|
// • a target that vanished mid-run is a SUCCESSFUL restore, not a failure —
|
||||||
|
// there is nothing to give back, and reporting it failed leaves a ledger row
|
||||||
|
// unresolved for ever over an object that is gone
|
||||||
|
// • `inForce()` reads the frame's `holds`, which is the only thing that can
|
||||||
|
// answer for a targeted key: there is no list of spawners to walk
|
||||||
|
// • a grant that reached NOBODY is a success, because an event nobody attended
|
||||||
|
// still happened — while a run the shard was never told to count is a 404
|
||||||
|
// • a non-stackable granted in quantity is refused at BOTH ends
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const uoLinkClient = require('../utils/uoLinkClient')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
require('./_setup')
|
||||||
|
const actions = require('../config/uoEventActions')
|
||||||
|
|
||||||
|
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||||
|
const leaseById = (id) => actions.LEASES.find((l) => l.id === id)
|
||||||
|
|
||||||
|
const STUBBED = ['getLeases', 'applyLease', 'releaseLease', 'grantItem', 'saveWorld']
|
||||||
|
|
||||||
|
let calls
|
||||||
|
let frame
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = { leases: [], apply: [], release: [], grant: [], save: [] }
|
||||||
|
frame = {
|
||||||
|
leases: [{ key: 'Spawner.MaxCount', kind: 'property', current: '3', held: false }],
|
||||||
|
holds: [],
|
||||||
|
}
|
||||||
|
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||||
|
saved.listSpawners = shardAtlas.listSpawners
|
||||||
|
|
||||||
|
uoLinkClient.getLeases = async (q) => {
|
||||||
|
calls.leases.push(q)
|
||||||
|
return { ok: true, status: 200, data: frame }
|
||||||
|
}
|
||||||
|
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
uoLinkClient.grantItem = async (b) => {
|
||||||
|
calls.grant.push(b)
|
||||||
|
return { ok: true, status: 200, data: { granted: 2, missed: [] } }
|
||||||
|
}
|
||||||
|
uoLinkClient.saveWorld = async (b) => { calls.save.push(b); return { ok: true, status: 200, data: {} } }
|
||||||
|
shardAtlas.listSpawners = async (opts) => {
|
||||||
|
calls.spawners = opts
|
||||||
|
return [
|
||||||
|
{ uniqueId: 'uid-1', name: 'fel orc fort', facet: 'Felucca', region: 'Britain', maxCount: 9 },
|
||||||
|
{ uniqueId: 'uid-2', name: null, facet: 'Trammel', region: null, landmark: null, maxCount: 1 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||||
|
shardAtlas.listSpawners = saved.listSpawners
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The targeted leases ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('every callable carries the target through to the shard', async () => {
|
||||||
|
// The one thing that cannot be got wrong quietly. Core composes the ledger ref
|
||||||
|
// as `<lease id>#<target>` and hands the target back on every call; a callable
|
||||||
|
// that ignored it would read, apply to and restore whichever spawner the shard
|
||||||
|
// happened to answer about, and nothing here or there would report an error.
|
||||||
|
const lease = leaseById('uo.spawner.maxcount')
|
||||||
|
const target = '003f11b8-9bfa-4587-991e-ca263004efe6'
|
||||||
|
|
||||||
|
const read = await lease.read({ target })
|
||||||
|
assert.deepEqual(read, { ok: true, value: '3' })
|
||||||
|
assert.deepEqual(calls.leases[0], { key: 'Spawner.MaxCount', target })
|
||||||
|
|
||||||
|
await lease.apply('30', new Date(Date.now() + 600_000), { target })
|
||||||
|
assert.equal(calls.apply[0].key, 'Spawner.MaxCount')
|
||||||
|
assert.equal(calls.apply[0].target, target)
|
||||||
|
// A DURATION, not the deadline — 11b's rule, unchanged by targeting. A shard
|
||||||
|
// whose clock runs fast would restore an absolute deadline the instant it
|
||||||
|
// took it.
|
||||||
|
assert.ok(calls.apply[0].holdMs > 0 && calls.apply[0].holdMs <= 600_000)
|
||||||
|
|
||||||
|
await lease.restore('3', { expected: '30', target })
|
||||||
|
assert.deepEqual(calls.release[0], {
|
||||||
|
key: 'Spawner.MaxCount',
|
||||||
|
target,
|
||||||
|
expected: '30',
|
||||||
|
baseline: '3',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target the shard cannot read refuses the lease rather than defaulting', async () => {
|
||||||
|
// The failure this guards is silent and permanent: a lease taken over a
|
||||||
|
// spawner that is not there records whatever came back as the baseline, and
|
||||||
|
// teardown then WRITES that baseline onto whatever next holds the id.
|
||||||
|
frame.leases = [{ key: 'Spawner.MaxCount', unreadable: "nothing on this shard has serial 0x99" }]
|
||||||
|
const refused = await leaseById('uo.spawner.maxcount').read({ target: '0x99' })
|
||||||
|
assert.equal(refused.ok, false)
|
||||||
|
assert.match(refused.error, /nothing on this shard has serial/)
|
||||||
|
|
||||||
|
// A row with neither a value nor a reason is refused too. The shard should
|
||||||
|
// always send one of them, and "it sent neither" must not read as zero.
|
||||||
|
frame.leases = [{ key: 'Spawner.MaxCount' }]
|
||||||
|
const empty = await leaseById('uo.spawner.maxcount').read({ target: 'uid-1' })
|
||||||
|
assert.equal(empty.ok, false)
|
||||||
|
assert.match(empty.error, /could not read/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a target that vanished mid-run is a successful restore, not a failure', async () => {
|
||||||
|
// 12a's `gone` in the lease plane's vocabulary. Somebody deleted the spawner
|
||||||
|
// while the run held it: there is nothing to give back and nothing is owed.
|
||||||
|
// Reported as a failure it would sit in the ledger unresolved for ever, over
|
||||||
|
// an object that no longer exists — and every sweep would try again.
|
||||||
|
uoLinkClient.releaseLease = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { kind: 'lease.ok', released: true, targetGone: true, reason: 'that object has been deleted' },
|
||||||
|
})
|
||||||
|
const done = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
|
||||||
|
assert.deepEqual(done, { ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drift is still drift, and is still not an error', async () => {
|
||||||
|
// Unchanged from 11b and asserted again because targeting rewrote the whole
|
||||||
|
// callable: core records drift as a distinct SUCCESSFUL outcome, so an error
|
||||||
|
// here would put the row on the retry ladder and eventually report the lease
|
||||||
|
// as vanished rather than as somebody having moved it.
|
||||||
|
uoLinkClient.releaseLease = async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: { kind: 'lease.drifted', current: '12' },
|
||||||
|
})
|
||||||
|
const drifted = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
|
||||||
|
assert.deepEqual(drifted, { ok: false, drifted: true, current: '12' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inForce reads the holds list, which is the only thing that can answer', async () => {
|
||||||
|
// A catalog walk can enumerate the KEYS but never the holds on a targeted one
|
||||||
|
// — there is no list of spawners to walk — so the frame carries every hold the
|
||||||
|
// shard has, and this is what reads it.
|
||||||
|
const lease = leaseById('uo.spawner.maxcount')
|
||||||
|
|
||||||
|
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: false })
|
||||||
|
|
||||||
|
frame.holds = [{ key: 'Spawner.MaxCount', target: 'uid-1', runId: '7' }]
|
||||||
|
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: true })
|
||||||
|
// ...and it is the hold on THIS target, not any hold on the key. A run holding
|
||||||
|
// one spawner must not make every other spawner look leased.
|
||||||
|
assert.deepEqual(await lease.inForce({ target: 'uid-2' }), { ok: true, held: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a shard that cannot answer is never read as "the lease is gone"', async () => {
|
||||||
|
// Core's posture everywhere: "I could not ask" must not be recorded as "it is
|
||||||
|
// gone", because the second orphans the row and stops teardown ever trying.
|
||||||
|
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, data: null })
|
||||||
|
const answer = await leaseById('uo.spawner.maxcount').inForce({ target: 'uid-1' })
|
||||||
|
assert.equal(answer.ok, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the seasonal lease is a three-value enum over eight events', () => {
|
||||||
|
// §G called `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and
|
||||||
|
// had it backwards: `EventStatus` has three values, `EventType` has nine
|
||||||
|
// entries — and one of those nine is excluded, so it is eight.
|
||||||
|
const lease = leaseById('uo.seasonal.status')
|
||||||
|
assert.equal(lease.type, 'string')
|
||||||
|
assert.deepEqual(lease.values, ['Inactive', 'Active', 'Seasonal'])
|
||||||
|
assert.equal(actions.SEASONAL_EVENTS.length, 8)
|
||||||
|
// TreasuresOfTokuno reads its own era rather than this status, so leasing it
|
||||||
|
// would apply cleanly and change nothing — §N10's "a capability that lies",
|
||||||
|
// and the one instance no runtime probe can catch.
|
||||||
|
assert.ok(!actions.SEASONAL_EVENTS.includes('TreasuresOfTokuno'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every targeted lease bounds what it can hold', () => {
|
||||||
|
// §F requires a range on the numeric types because, unlike a cap, a bad lease
|
||||||
|
// value is in force the moment it is applied. Restated over the five because
|
||||||
|
// they are built by a shared factory: one missing bound would be missing in a
|
||||||
|
// way no single declaration shows.
|
||||||
|
for (const lease of actions.LEASES) {
|
||||||
|
if (lease.id === 'uo.playercaps.skillcap') continue
|
||||||
|
assert.ok(lease.maxDurationMs > 0, `${lease.id} has no duration bound`)
|
||||||
|
if (lease.type === 'int' || lease.type === 'float') {
|
||||||
|
assert.ok(Number.isFinite(lease.min) && Number.isFinite(lease.max), `${lease.id} has no range`)
|
||||||
|
assert.ok(lease.min <= lease.max, `${lease.id} has min above max`)
|
||||||
|
}
|
||||||
|
if (lease.type === 'string') {
|
||||||
|
assert.ok(Array.isArray(lease.values) && lease.values.length, `${lease.id} has no value set`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The spawner source ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('the spawner source searches, and says so', async () => {
|
||||||
|
// The first source with more entries than a dropdown holds: 6,707 spawn points
|
||||||
|
// against MAX_OPTIONS' 2,000. A flat list would drop two thirds of the world
|
||||||
|
// and say nothing about which two thirds.
|
||||||
|
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.spawners')
|
||||||
|
assert.equal(source.searchable, true)
|
||||||
|
|
||||||
|
const rows = await source.resolve({ q: 'orc' })
|
||||||
|
assert.equal(calls.spawners.q, 'orc')
|
||||||
|
assert.equal(calls.spawners.limit, actions.SPAWNER_OPTIONS)
|
||||||
|
|
||||||
|
// The value is the UniqueId, because it is the only name for one particular
|
||||||
|
// spawner that exists off the shard.
|
||||||
|
assert.deepEqual(rows[0], { value: 'uid-1', label: 'fel orc fort', group: 'Britain' })
|
||||||
|
// A nameless spawner still answers, labelled by its id. It is still a spawner
|
||||||
|
// somebody may need to turn down, and dropping it would be a dropdown quietly
|
||||||
|
// missing rows again.
|
||||||
|
assert.deepEqual(rows[1], { value: 'uid-2', label: 'uid-2', group: 'Trammel' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The one-shots ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('a grant sends a run and never a recipient list', async () => {
|
||||||
|
// The shard has held this run's participation ledger since it opened, keyed by
|
||||||
|
// the same serials core stores as `member_key`. Sending a list would put it on
|
||||||
|
// the wire twice with a window in which the two disagree — and would have
|
||||||
|
// needed a core surface handing a module core's own participants.
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 500, where: 'bank' },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, true)
|
||||||
|
assert.deepEqual(calls.grant[0], {
|
||||||
|
runId: 7,
|
||||||
|
item: 'gold',
|
||||||
|
amount: 500,
|
||||||
|
hue: undefined,
|
||||||
|
name: undefined,
|
||||||
|
where: 'bank',
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
})
|
||||||
|
assert.equal(out.detail.granted, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a grant that reached nobody is a success', async () => {
|
||||||
|
// An event nobody attended still happened. Reported as a failure the run would
|
||||||
|
// retry against a ledger that will be just as empty next time, and pause. The
|
||||||
|
// shard draws the distinction that matters: a run it was never told to count
|
||||||
|
// is a 404, which fails below.
|
||||||
|
uoLinkClient.grantItem = async () => ({ ok: true, status: 200, data: { granted: 0, missed: [] } })
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, true)
|
||||||
|
assert.equal(out.detail.granted, 0)
|
||||||
|
|
||||||
|
uoLinkClient.grantItem = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
data: { reason: 'run 7 has no participation ledger open on this shard' },
|
||||||
|
})
|
||||||
|
const missing = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(missing.ok, false)
|
||||||
|
// 404 is permanent: the ledger will not appear because we asked again.
|
||||||
|
assert.equal(missing.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-stackable granted in quantity is refused before the wire', async () => {
|
||||||
|
// Five cloaks would be five items — five chances to overflow a backpack
|
||||||
|
// halfway through with no way to say which half landed. Refused here so the
|
||||||
|
// author sees it on the form, and refused again on the shard because this copy
|
||||||
|
// of the allowlist is the one that can be wrong.
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'cloak', amount: 3 },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, false)
|
||||||
|
assert.equal(out.retry, false)
|
||||||
|
assert.match(out.error, /does not stack/)
|
||||||
|
assert.equal(calls.grant.length, 0)
|
||||||
|
|
||||||
|
const unknown = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'castle', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(unknown.ok, false)
|
||||||
|
assert.equal(unknown.retry, false)
|
||||||
|
assert.equal(calls.grant.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a grant is retryable, and protocol 6 is the reason', async () => {
|
||||||
|
// §G called a grant un-retryable because a lost acknowledgement and a grant
|
||||||
|
// that never applied were the same event — the argument that made
|
||||||
|
// `uo.broadcast` answer `retry: false` in Phase 9. An idempotency key closes
|
||||||
|
// it: a repeat is answered by the original reply, so a retried grant cannot be
|
||||||
|
// one winner receiving two.
|
||||||
|
uoLinkClient.grantItem = async () => ({ ok: false, status: 503, data: null })
|
||||||
|
const out = await byId('uo.item.grant').perform({
|
||||||
|
runId: 7,
|
||||||
|
idempotencyKey: 'k',
|
||||||
|
params: { item: 'gold', amount: 1 },
|
||||||
|
})
|
||||||
|
assert.equal(out.ok, false)
|
||||||
|
assert.notEqual(out.retry, false)
|
||||||
|
// And the action declares itself irreversible, which is the honest class: the
|
||||||
|
// world is altered and cannot be put back.
|
||||||
|
assert.equal(byId('uo.item.grant').risk, 'irreversible')
|
||||||
|
assert.equal(byId('uo.item.grant').reversible, 'none')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a save refused for coming too soon is retried, not abandoned', async () => {
|
||||||
|
// 429 is the shard's rate limit and is the one refusal on this plane that
|
||||||
|
// waiting fixes. It is deliberately not in PERMANENT_STATUSES, so a phase
|
||||||
|
// boundary is retried rather than dropped.
|
||||||
|
assert.ok(!actions.PERMANENT_STATUSES.has(429))
|
||||||
|
uoLinkClient.saveWorld = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
data: { reason: 'this shard saves at most every 300 seconds, and the last save was 12 seconds ago' },
|
||||||
|
})
|
||||||
|
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
|
||||||
|
assert.equal(out.ok, false)
|
||||||
|
assert.notEqual(out.retry, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a save reports only that it started', async () => {
|
||||||
|
// What actually happened rides `world.save.before`/`after` on the event stream.
|
||||||
|
// Asserting anything more here would be asserting something the reply does not
|
||||||
|
// know.
|
||||||
|
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
|
||||||
|
assert.deepEqual(out, { ok: true, detail: { started: true } })
|
||||||
|
assert.deepEqual(calls.save[0], { idempotencyKey: 'k' })
|
||||||
|
})
|
||||||
@@ -351,10 +351,17 @@ function tagValue(block, name) {
|
|||||||
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||||
* flat, so a per-record regex sweep is both correct and cheap.
|
* flat, so a per-record regex sweep is both correct and cheap.
|
||||||
*
|
*
|
||||||
* Only the fields the site can actually show are kept. Everything to do with
|
* Only the fields the site can actually use are kept. Everything to do with
|
||||||
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
* triggering, refractory windows, proximity, sequential spawning and sounds is
|
||||||
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
* dropped here rather than downstream, which is what keeps the parsed atlas
|
||||||
* committed artifact under 1 MB.
|
* small.
|
||||||
|
*
|
||||||
|
* **`UniqueId` was on that list until Phase 12b and is now kept**, because a
|
||||||
|
* property lease has to name one particular spawner and this is the only name
|
||||||
|
* for one that exists off-shard. The line that justified dropping it cited a
|
||||||
|
* committed artifact; there is no committed artifact — `spawnAtlasSource.js`
|
||||||
|
* says so in its own header ("nothing is precomputed and committed") — so the
|
||||||
|
* only real cost was ~37 bytes a row in a table, and it bought a dropdown.
|
||||||
*
|
*
|
||||||
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||||
@@ -389,6 +396,14 @@ function parsePoints(source) {
|
|||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
name: tagValue(block, 'Name'),
|
name: tagValue(block, 'Name'),
|
||||||
|
// **Kept from Phase 12b, having been discarded since the atlas shipped.**
|
||||||
|
// It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files
|
||||||
|
// and carries it on the live spawner — so it is the ONE way an authoring
|
||||||
|
// form can name a particular spawner without the shard being up. A serial
|
||||||
|
// cannot do that job: serials are assigned when the world is built and
|
||||||
|
// nothing off-shard knows them, which is why a property lease that could
|
||||||
|
// only be addressed by serial could have no dropdown at all.
|
||||||
|
uniqueId: tagValue(block, 'UniqueId'),
|
||||||
facet,
|
facet,
|
||||||
x: toInt(tagValue(block, 'X')),
|
x: toInt(tagValue(block, 'X')),
|
||||||
y: toInt(tagValue(block, 'Y')),
|
y: toInt(tagValue(block, 'Y')),
|
||||||
|
|||||||
@@ -175,8 +175,13 @@ function hashSources(root) {
|
|||||||
*
|
*
|
||||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||||
* seconds in the source, decided by `DelayInSec`).
|
* seconds in the source, decided by `DelayInSec`).
|
||||||
|
* 3 — the decoration index, from `Data/Decoration/**\/*.cfg`.
|
||||||
|
* 4 — a spawn point keeps its `UniqueId`, which is what a property lease
|
||||||
|
* targets (Phase 12b). The bump is what re-reads a tree the boot path
|
||||||
|
* would otherwise skip on an unchanged hash — the source files have not
|
||||||
|
* changed, only what is kept from them.
|
||||||
*/
|
*/
|
||||||
const PARSER_VERSION = 3
|
const PARSER_VERSION = 4
|
||||||
|
|
||||||
/** True when two source fingerprints describe the same tree. */
|
/** True when two source fingerprints describe the same tree. */
|
||||||
function sameSources(a, b) {
|
function sameSources(a, b) {
|
||||||
|
|||||||
@@ -236,28 +236,45 @@ const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
|
|||||||
// holding it. One read serves both questions core asks — `read()` wants the
|
// holding it. One read serves both questions core asks — `read()` wants the
|
||||||
// current value, `inForce()` wants to know whether the shard still has a record
|
// current value, `inForce()` wants to know whether the shard still has a record
|
||||||
// of the hold — so a lease costs one round trip, not two.
|
// of the hold — so a lease costs one round trip, not two.
|
||||||
const getLeases = () => call('/lease')
|
// **A targeted lease must name its target here** (protocol 7 part b). A key like
|
||||||
|
// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no
|
||||||
|
// single `current` and the catalog walk cannot fill one in — while `read()` needs
|
||||||
|
// exactly one value for exactly one target before it applies anything. Naming both
|
||||||
|
// narrows the frame to that row and fills it.
|
||||||
|
//
|
||||||
|
// The frame also carries `holds`: every hold this shard has, whatever key or
|
||||||
|
// target. A catalog walk enumerates the KEYS but can never enumerate the holds on
|
||||||
|
// a targeted one — there is no list of spawners to walk — so `inForce()` reads
|
||||||
|
// that rather than the row's `held` flag.
|
||||||
|
const getLeases = ({ key, target } = {}) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (key) params.set('key', key)
|
||||||
|
if (target) params.set('target', target)
|
||||||
|
const query = params.toString()
|
||||||
|
return call(query ? `/lease?${query}` : '/lease')
|
||||||
|
}
|
||||||
|
|
||||||
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
|
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
|
||||||
// computed here and honoured there is a deadline measured against two clocks, and
|
// computed here and honoured there is a deadline measured against two clocks, and
|
||||||
// a shard running ten minutes fast would restore a ten-minute lease the moment it
|
// a shard running ten minutes fast would restore a ten-minute lease the moment it
|
||||||
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
|
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
|
||||||
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
|
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
|
||||||
const applyLease = ({ key, value, holdMs, untilMs, runId, idempotencyKey }) =>
|
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
|
||||||
call('/lease', {
|
call('/lease', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { key, value: String(value), holdMs, untilMs, runId, idempotencyKey },
|
body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey },
|
||||||
})
|
})
|
||||||
|
|
||||||
// `expected` is what this run applied and `baseline` is what to put back, both out
|
// `expected` is what this run applied and `baseline` is what to put back, both out
|
||||||
// of core's ledger rather than the shard's memory — so a release still works after
|
// of core's ledger rather than the shard's memory — so a release still works after
|
||||||
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
|
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
|
||||||
// reverts every config lease by design) answers honestly instead of refusing.
|
// reverts every config lease by design) answers honestly instead of refusing.
|
||||||
const releaseLease = ({ key, expected, baseline, idempotencyKey }) =>
|
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
|
||||||
call('/lease/release', {
|
call('/lease/release', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
key,
|
key,
|
||||||
|
target,
|
||||||
expected: expected == null ? undefined : String(expected),
|
expected: expected == null ? undefined : String(expected),
|
||||||
baseline: baseline == null ? undefined : String(baseline),
|
baseline: baseline == null ? undefined : String(baseline),
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -324,6 +341,35 @@ const respondPage = (pageId, { message, close }) =>
|
|||||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||||
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
||||||
|
|
||||||
|
// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ────────────
|
||||||
|
//
|
||||||
|
// Neither owned nor borrowed: done is done. Both are gated on the shard by the
|
||||||
|
// same `Bridge.EventsEnabled` as the rest of the plane.
|
||||||
|
|
||||||
|
// What this shard will actually build, with the bounds it will build within. The
|
||||||
|
// module holds the same allowlist for its dropdown, so the form still works with
|
||||||
|
// the shard down; this is what is true when that copy is wrong.
|
||||||
|
const getGrantCatalog = () => call('/items')
|
||||||
|
|
||||||
|
// **The recipients are not sent.** The shard has held this run's participation
|
||||||
|
// ledger since it opened, keyed by the same character serials core stores as
|
||||||
|
// `member_key`, so the grant names a run and the shard resolves who was there.
|
||||||
|
// Sending a list would put the same list on the wire twice with a window in which
|
||||||
|
// the two disagree — and would have needed a core surface handing a module core's
|
||||||
|
// own participants.
|
||||||
|
const grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) =>
|
||||||
|
call('/items/grant', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Starts a save. What actually happened rides `world.save.before`/`after` on the
|
||||||
|
// event stream, which have been there since protocol 2 — so this asserts only that
|
||||||
|
// the save was started, and a caller that needs the completion watches the feed it
|
||||||
|
// is already connected to.
|
||||||
|
const saveWorld = ({ idempotencyKey } = {}) =>
|
||||||
|
call('/world/save', { method: 'POST', body: { idempotencyKey } })
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
TIMEOUT_MS,
|
TIMEOUT_MS,
|
||||||
invalidateConfig,
|
invalidateConfig,
|
||||||
@@ -361,6 +407,9 @@ module.exports = {
|
|||||||
spawnWorld,
|
spawnWorld,
|
||||||
ownedWorld,
|
ownedWorld,
|
||||||
despawnWorld,
|
despawnWorld,
|
||||||
|
getGrantCatalog,
|
||||||
|
grantItem,
|
||||||
|
saveWorld,
|
||||||
adminKick,
|
adminKick,
|
||||||
adminBan,
|
adminBan,
|
||||||
adminUnban,
|
adminUnban,
|
||||||
|
|||||||
Reference in New Issue
Block a user