feat(events): the UO half of the Event System, and the core pin comes home (Phase 16b cutover, 3 of 6) #34
@@ -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