Merge pull request 'feat(events): what an author borrows, and two one-shots (Phase 12b)' (#32) from feature/events-p12b-borrowed-and-oneshots into edge
Reviewed-on: #32
This commit is contained in:
@@ -259,6 +259,13 @@ const BUDGETS = [
|
||||
unit: 'minutes',
|
||||
description: 'Total minutes of temporary gate this run may open, across every gate.',
|
||||
},
|
||||
{
|
||||
id: 'uo.rewards',
|
||||
label: 'Items granted',
|
||||
unit: 'items',
|
||||
description:
|
||||
'Items handed to participants. Counted per item rather than per grant: a step giving 500 gold to 40 people is a different imposition from one giving a candle to 40 people, and a count of grants would price them the same.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Participation (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ────────────
|
||||
@@ -510,6 +517,79 @@ async function reconcileOwned({ runId, resources }) {
|
||||
}
|
||||
|
||||
/** The three fields every world verb shares, so five declarations cannot drift apart. */
|
||||
// ── The one-shots' vocabulary (Phase 12b) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* What this shard will grant, mirroring the plugin's own allowlist.
|
||||
*
|
||||
* **Two copies of a short list, deliberately.** This one is what makes a bad
|
||||
* value a refusal on an authoring form; the plugin's is what is true when this
|
||||
* one is wrong — the same argument the lease bounds have carried since 11b. It
|
||||
* is held here rather than read from `GET /items` because §F is explicit that an
|
||||
* option source must resolve with the game side down, and an authoring screen a
|
||||
* shard outage can make unusable is a worse failure than the typo the dropdown
|
||||
* prevents.
|
||||
*
|
||||
* `stackable` is carried because it changes what an `amount` MEANS: five gold is
|
||||
* one item with `Amount = 5`, and five cloaks would be five items — five chances
|
||||
* to overflow a backpack halfway through with no way to say which half landed.
|
||||
* Both ends refuse a non-stackable in quantity.
|
||||
*/
|
||||
const GRANTABLE = [
|
||||
{ key: 'gold', label: 'Gold', stackable: true },
|
||||
{ key: 'cloak', label: 'Cloak', stackable: false },
|
||||
{ key: 'sandals', label: 'Sandals', stackable: false },
|
||||
{ key: 'candle', label: 'Candle', stackable: false },
|
||||
{ key: 'earrings', label: 'Silver earrings', stackable: false },
|
||||
{ key: 'fireworks', label: 'Fireworks wand', stackable: false },
|
||||
{ key: 'bottle', label: 'Message in a bottle', stackable: false },
|
||||
]
|
||||
|
||||
/** The bound on one hand, mirroring `Bridge.EventsMaxGrantStack`. */
|
||||
const MAX_GRANT_STACK = 1000
|
||||
|
||||
/**
|
||||
* The seasonal events a lease may name — eight of `EventType`'s nine.
|
||||
*
|
||||
* `TreasuresOfTokuno` is absent because `SeasonalEventEntry.IsActive()`
|
||||
* special-cases it and reads `TreasuresOfTokuno.DropEra` rather than `Status`,
|
||||
* so a lease on it would write a field nothing consults: the write succeeds, the
|
||||
* value reads back, the compare-and-set restore passes, and nothing in the world
|
||||
* changes. §N10 calls that "a capability that lies", and it is the one instance
|
||||
* no runtime probe can catch — which is why it is excluded by name at both ends.
|
||||
*/
|
||||
const SEASONAL_EVENTS = [
|
||||
'VirtueArtifacts',
|
||||
'TreasuresOfKotlCity',
|
||||
'SorcerersDungeon',
|
||||
'TreasuresOfDoom',
|
||||
'TreasuresOfKhaldun',
|
||||
'KrampusEncounter',
|
||||
'RisingTide',
|
||||
'Fellowship',
|
||||
]
|
||||
|
||||
/** ServUO's own display names for them, from `SeasonalEventSystem.LoadEntries()`. */
|
||||
const SEASONAL_LABELS = {
|
||||
VirtueArtifacts: 'Virtue Artifacts',
|
||||
TreasuresOfKotlCity: 'Treasures of Kotl',
|
||||
SorcerersDungeon: "Sorcerer's Dungeon",
|
||||
TreasuresOfDoom: 'Treasures of Doom',
|
||||
TreasuresOfKhaldun: 'Treasures of Khaldun',
|
||||
KrampusEncounter: 'Krampus Encounter',
|
||||
RisingTide: 'Rising Tide',
|
||||
Fellowship: 'Fellowship',
|
||||
}
|
||||
|
||||
/**
|
||||
* How many spawners one search answers with.
|
||||
*
|
||||
* Well under `MAX_OPTIONS` on purpose: this is a typeahead rather than a select,
|
||||
* and a hundred rows is already more than anybody reads. The bound that matters
|
||||
* is that the SEARCH reaches the whole tree, which it does.
|
||||
*/
|
||||
const SPAWNER_OPTIONS = 100
|
||||
|
||||
const OWNED_COMMON = {
|
||||
// Something appears in the world where there was nothing. §K's default-off
|
||||
// line falls between `inspect` and `change`, so an operator switches these on
|
||||
@@ -1451,6 +1531,178 @@ const ACTIONS = [
|
||||
},
|
||||
},
|
||||
|
||||
// ── The one-shots (Phase 12b) ────────────────────────────────────────────
|
||||
//
|
||||
// Neither owned nor borrowed. Nothing is ledgered because there is nothing
|
||||
// core could come back for, which is what `reversible: 'none'` says.
|
||||
{
|
||||
id: 'uo.item.grant',
|
||||
label: 'Grant an item',
|
||||
description:
|
||||
"Put an item into the hands of everyone who took part in this run. Irreversible: an object in a player's backpack cannot be recalled.",
|
||||
|
||||
// **`irreversible`, not `change`.** The world is altered and cannot be put
|
||||
// back, which is the honest class and the one that makes an author's
|
||||
// default `on_failure` a pause rather than a retry-then-skip.
|
||||
risk: 'irreversible',
|
||||
// Nothing to give back. A `ledger` here would put a row in the cleanup
|
||||
// ledger that teardown could never resolve — §G's `reversible: 'none'` for
|
||||
// UO specifically, and the reason is the game's rather than the platform's.
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
cost: (p) => ({ 'uo.rewards': Number(p.amount) || 1 }),
|
||||
|
||||
params: [
|
||||
{
|
||||
name: 'item',
|
||||
type: 'string',
|
||||
required: true,
|
||||
example: 'gold',
|
||||
source: 'uo.options.items',
|
||||
description: 'What to hand out. The list is the shard’s own allowlist.',
|
||||
},
|
||||
{
|
||||
name: 'amount',
|
||||
type: 'int',
|
||||
required: true,
|
||||
example: 500,
|
||||
description: `How many each. Stackable items only; ${MAX_GRANT_STACK} at most.`,
|
||||
},
|
||||
{
|
||||
name: 'where',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'backpack',
|
||||
description: 'backpack or bank. Left out, the backpack.',
|
||||
},
|
||||
{
|
||||
name: 'hue',
|
||||
type: 'int',
|
||||
required: false,
|
||||
example: 1157,
|
||||
description: 'UO colour id, for a commemorative reward.',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: 'a champion’s cloak',
|
||||
description: 'What it is called when a player looks at it.',
|
||||
},
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
const item = String(params.item || '').trim()
|
||||
if (!item) return { ok: false, retry: false, error: 'pick something to grant' }
|
||||
|
||||
const known = GRANTABLE.find((g) => g.key === item)
|
||||
if (!known) {
|
||||
return { ok: false, retry: false, error: `this shard does not grant "${item}"` }
|
||||
}
|
||||
|
||||
// Its own check rather than `counted()`: that one's refusal reads "place 1
|
||||
// to N items at a time", which is the spawn verbs' sentence and wrong here
|
||||
// — nothing is being placed. The bound is the same shape and mirrors
|
||||
// `Bridge.EventsMaxGrantStack`.
|
||||
const amount = Number(params.amount)
|
||||
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GRANT_STACK) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `grant 1 to ${MAX_GRANT_STACK} at a time, and "${params.amount}" is not that`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!known.stackable && amount > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${known.label} does not stack, so it can only be granted one at a time`,
|
||||
}
|
||||
}
|
||||
|
||||
const hue = optionalInt(params.hue, { max: 65535, name: 'colour' })
|
||||
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
|
||||
|
||||
const where = String(params.where || 'backpack').trim().toLowerCase()
|
||||
if (where !== 'backpack' && where !== 'bank') {
|
||||
return { ok: false, retry: false, error: `"${params.where}" is not backpack or bank` }
|
||||
}
|
||||
|
||||
// **The dry run stops here, and it has checked everything it can.** What
|
||||
// it deliberately does not do is ask the shard who is present: a verify
|
||||
// that failed because a run has no ledger open would refuse every grant
|
||||
// authored before its own event ran, which is every grant.
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await uoLinkClient.grantItem({
|
||||
runId,
|
||||
item,
|
||||
amount,
|
||||
hue: hue.value,
|
||||
name: params.name ? String(params.name).slice(0, 40) : undefined,
|
||||
where,
|
||||
idempotencyKey,
|
||||
})
|
||||
|
||||
// **Retryable, and protocol 6 is the whole reason.** §G called a grant
|
||||
// un-retryable because a lost acknowledgement and a grant that never
|
||||
// applied were the same event — exactly the argument that made
|
||||
// `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey`
|
||||
// closes that: a repeat is answered by the original reply, so a retried
|
||||
// grant cannot be one winner receiving two.
|
||||
if (!result.ok) return sidecarFailure(result, 'grant')
|
||||
|
||||
const granted = Number(result.data?.granted) || 0
|
||||
const missed = (result.data && result.data.missed) || []
|
||||
|
||||
// A grant that reached nobody is a SUCCESS, and the distinction is the
|
||||
// shard's: a run it was never told to count is a 404 above, while a run
|
||||
// whose ledger is open and empty answers 200 with `granted: 0`. An event
|
||||
// nobody attended still happened, and retrying against the same empty
|
||||
// ledger would pause a run for ever.
|
||||
return {
|
||||
ok: true,
|
||||
detail: { granted, missed: missed.length, ...(missed.length ? { why: missed.slice(0, 10) } : {}) },
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'uo.world.save',
|
||||
label: 'Save the world',
|
||||
description:
|
||||
'Ask the shard to write a world save. Useful as a phase boundary — the point after which what the event has done so far survives a crash.',
|
||||
|
||||
// Nothing is created and nothing is altered; the world is written to disk.
|
||||
// `inspect` would be a lie (it stops the world for a moment) and `change`
|
||||
// is what that is.
|
||||
risk: 'change',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
|
||||
params: [],
|
||||
|
||||
async perform({ idempotencyKey, verify }) {
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const result = await uoLinkClient.saveWorld({ idempotencyKey })
|
||||
|
||||
// 429 is the shard's save rate limit, and it is the one refusal on this
|
||||
// plane that waiting fixes: the same request succeeds once the interval
|
||||
// passes. It is not in `PERMANENT_STATUSES`, so `sidecarFailure`
|
||||
// classifies it retry without needing an arm of its own — which is what
|
||||
// makes a phase boundary retried rather than abandoned.
|
||||
if (!result.ok) return sidecarFailure(result, 'world save')
|
||||
|
||||
// What actually happened rides `world.save.before`/`after` on the event
|
||||
// stream. This step reports only that the save was started, because that
|
||||
// is the only thing the reply knows.
|
||||
return { ok: true, detail: { started: true } }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -1507,6 +1759,142 @@ async function leaseRow(key) {
|
||||
return rows.find((r) => r && r.key === key) || null
|
||||
}
|
||||
|
||||
// ── The targeted leases (protocol 7 part b, Phase 12b) ─────────────────────
|
||||
//
|
||||
// What an event BORROWS. Five keys over two planes, and every one of them is
|
||||
// targeted — a property lives on a particular object and a seasonal status on a
|
||||
// particular event, so the lease id names the capability and the target names
|
||||
// the thing.
|
||||
//
|
||||
// **The module still never writes a lease and never bounds one.** An author puts
|
||||
// `core.lease` in a step naming a lease, a target, a value and a number of
|
||||
// minutes; core reads the baseline, reserves `<lease id>#<target>` against the
|
||||
// two-events-one-target index, applies the value with its deadline and restores
|
||||
// it at teardown. What is here is the four callables, exactly as the config
|
||||
// lease has had since 11b — with the target now handed to each of them.
|
||||
|
||||
/** Twelve hours, the same ceiling the config lease carries. */
|
||||
const MAX_PROP_LEASE_MS = 12 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* One row of the shard's lease frame, for one key and one target.
|
||||
*
|
||||
* A targeted key has no single `current`, so the shard is asked about the one
|
||||
* that matters rather than walked. Null when the frame could not be read at all,
|
||||
* which the callables turn into a refusal rather than a value.
|
||||
*/
|
||||
async function leaseRowFor(key, target) {
|
||||
const result = await uoLinkClient.getLeases({ key, target })
|
||||
if (!result.ok) return null
|
||||
const rows = (result.data && result.data.leases) || []
|
||||
return rows.find((r) => r && r.key === key) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the shard is still holding this key on this target.
|
||||
*
|
||||
* **Read from `holds`, not from a row's `held` flag**, and the difference only
|
||||
* appears on a targeted key: `held` is filled in against the target the frame
|
||||
* was narrowed to, so it answers for the row that was asked about — while
|
||||
* `holds` is every hold the shard actually has. They agree here; `holds` is used
|
||||
* because it is the one that stays true if the frame is ever asked without a
|
||||
* target, and because it is the list a reconcile after a long outage wants.
|
||||
*/
|
||||
async function leaseHeld(key, target) {
|
||||
const result = await uoLinkClient.getLeases({ key, target })
|
||||
if (!result.ok) return null
|
||||
const holds = (result.data && result.data.holds) || []
|
||||
return holds.some((h) => h && h.key === key && String(h.target || '') === String(target || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The four callables every targeted lease shares.
|
||||
*
|
||||
* They differ only in which key they name, so they are built rather than
|
||||
* repeated: five copies of this would be five chances for one of them to forget
|
||||
* the drift check, which is the one thing §F says a lease must not be allowed to
|
||||
* skip.
|
||||
*/
|
||||
function targetedLease({ id, key, label, description, type, min, max, values, targetLabel, source }) {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
type,
|
||||
...(min === undefined ? {} : { min }),
|
||||
...(max === undefined ? {} : { max }),
|
||||
...(values ? { values } : {}),
|
||||
maxDurationMs: MAX_PROP_LEASE_MS,
|
||||
target: {
|
||||
label: targetLabel,
|
||||
source,
|
||||
example: source === 'uo.options.spawners' ? '003f11b8-9bfa-4587-991e-ca263004efe6' : 'Fellowship',
|
||||
},
|
||||
|
||||
async read({ target } = {}) {
|
||||
const row = await leaseRowFor(key, target)
|
||||
if (!row) return { ok: false, error: 'the shard did not report its lease catalog' }
|
||||
// **`unreadable` is a refusal, and `current` missing is too.** A spawner
|
||||
// that has been deleted answers with a reason rather than a value, and
|
||||
// taking the lease anyway would record a fictional baseline and later
|
||||
// write it onto whatever next held that id.
|
||||
if (row.unreadable) return { ok: false, error: row.unreadable }
|
||||
if (row.current === undefined || row.current === null) {
|
||||
return { ok: false, error: `the shard could not read ${label} for that target` }
|
||||
}
|
||||
return { ok: true, value: row.current }
|
||||
},
|
||||
|
||||
async apply(value, until, { target } = {}) {
|
||||
const holdMs = new Date(until).getTime() - Date.now()
|
||||
if (!Number.isFinite(holdMs) || holdMs <= 0) {
|
||||
return { ok: false, error: 'the lease deadline has already passed' }
|
||||
}
|
||||
const result = await uoLinkClient.applyLease({
|
||||
key,
|
||||
target,
|
||||
value,
|
||||
holdMs: Math.round(holdMs),
|
||||
untilMs: new Date(until).getTime(),
|
||||
})
|
||||
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease') }
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
async restore(baseline, { expected, target } = {}) {
|
||||
const result = await uoLinkClient.releaseLease({ key, target, expected, baseline })
|
||||
|
||||
if (result.ok && result.data && result.data.kind === 'lease.drifted') {
|
||||
return { ok: false, drifted: true, current: result.data.current }
|
||||
}
|
||||
|
||||
// **A target that no longer exists is a successful release**, not a
|
||||
// failure. Somebody deleted the spawner mid-run: there is nothing to
|
||||
// restore and nothing owed, and reporting it as failed would leave a
|
||||
// ledger row unresolved for ever over an object that is gone. It is 12a's
|
||||
// `gone` in the lease plane's vocabulary.
|
||||
if (result.ok && result.data && result.data.targetGone === true) return { ok: true }
|
||||
|
||||
if (!result.ok) return { ok: false, error: sidecarReason(result, 'lease release') }
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
async inForce({ target } = {}) {
|
||||
const held = await leaseHeld(key, target)
|
||||
if (held === null) return { ok: false, error: 'the shard did not report its lease catalog' }
|
||||
return { ok: true, held }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const SPAWNER_TARGET = {
|
||||
targetLabel: 'Which spawner',
|
||||
source: 'uo.options.spawners',
|
||||
}
|
||||
|
||||
/** Twenty-four hours in seconds, the bound on a respawn window. */
|
||||
const MAX_SPAWN_DELAY_SEC = 86400
|
||||
|
||||
const LEASES = [
|
||||
{
|
||||
id: 'uo.playercaps.skillcap',
|
||||
@@ -1590,6 +1978,85 @@ const LEASES = [
|
||||
return { ok: true, held: row.held === true }
|
||||
},
|
||||
},
|
||||
// **`MaxCount`, not `Amount`.** EVENTS_PLAN.md named `Spawner.Amount`; there
|
||||
// is no such property on ServUO 57.4. The count is `MaxCount` on BOTH
|
||||
// `Spawner` and `XmlSpawner`, which share all four names here — a fact worth
|
||||
// knowing rather than a convenience, because the shard's own `Spawns/*.xml`
|
||||
// load as XmlSpawners while `[add spawner` makes the native one, and a catalog
|
||||
// that named only one of them would work until the day it did not.
|
||||
targetedLease({
|
||||
id: 'uo.spawner.maxcount',
|
||||
key: 'Spawner.MaxCount',
|
||||
label: 'Spawner: how many at once',
|
||||
description:
|
||||
'How many creatures one spawner keeps alive. Takes effect on its next tick, so an invasion turns a spawner up for its duration and it winds back down at teardown.',
|
||||
type: 'int',
|
||||
min: 0,
|
||||
max: 100,
|
||||
...SPAWNER_TARGET,
|
||||
}),
|
||||
// **Seconds, and the shard converts.** The property is a `TimeSpan` and the
|
||||
// lease type vocabulary is int/float/bool/string with no duration in it. The
|
||||
// unit is seconds rather than minutes because the spawn files' own
|
||||
// `DelayInSec` flag proves both are in use on a real tree, and a unit that
|
||||
// cannot express five seconds cannot express this shard's own data.
|
||||
targetedLease({
|
||||
id: 'uo.spawner.mindelay',
|
||||
key: 'Spawner.MinDelay',
|
||||
label: 'Spawner: shortest respawn wait',
|
||||
description: 'The shortest a spawner waits before replacing what was killed, in seconds.',
|
||||
type: 'int',
|
||||
min: 0,
|
||||
max: MAX_SPAWN_DELAY_SEC,
|
||||
...SPAWNER_TARGET,
|
||||
}),
|
||||
targetedLease({
|
||||
id: 'uo.spawner.maxdelay',
|
||||
key: 'Spawner.MaxDelay',
|
||||
label: 'Spawner: longest respawn wait',
|
||||
description: 'The longest a spawner waits before replacing what was killed, in seconds.',
|
||||
type: 'int',
|
||||
min: 0,
|
||||
max: MAX_SPAWN_DELAY_SEC,
|
||||
...SPAWNER_TARGET,
|
||||
}),
|
||||
targetedLease({
|
||||
id: 'uo.spawner.running',
|
||||
key: 'Spawner.Running',
|
||||
label: 'Spawner: running',
|
||||
description:
|
||||
'Whether a spawner runs at all. Switching one off for the length of an event is how a venue is cleared without deleting anything.',
|
||||
type: 'bool',
|
||||
...SPAWNER_TARGET,
|
||||
}),
|
||||
// **A three-value enum over eight events, not a nine-way choice.** §G called
|
||||
// `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and had it
|
||||
// backwards: `EventStatus` has three values and it is `EventType` that has
|
||||
// nine entries.
|
||||
//
|
||||
// Eight rather than nine because `TreasuresOfTokuno` is excluded on the shard:
|
||||
// `IsActive()` special-cases it and reads `TreasuresOfTokuno.DropEra` instead
|
||||
// of `Status`, so leasing it would write a field nothing consults — the write
|
||||
// succeeds, the value reads back, a compare-and-set restore passes, and the
|
||||
// capability does nothing at all. That is precisely the failure §N10's
|
||||
// self-check exists for, and it is the one instance no runtime probe can
|
||||
// catch, so it is caught by reading the source.
|
||||
//
|
||||
// And it is not a small toggle: `OnStatusChange()` calls a `CheckEnabled()`
|
||||
// that generates or removes world content for six of the eight. It is safe —
|
||||
// ServUO does exactly this to itself from a staff gump — but an author
|
||||
// scheduling one should know it is more than a flag.
|
||||
targetedLease({
|
||||
id: 'uo.seasonal.status',
|
||||
key: 'Seasonal.Status',
|
||||
label: 'Seasonal event status',
|
||||
description:
|
||||
"Switch one of ServUO's own seasonal events on or off for the length of a run. Six of the eight generate or remove world content when they change, so this is a bigger lever than it looks.",
|
||||
type: 'string',
|
||||
values: ['Inactive', 'Active', 'Seasonal'],
|
||||
targetLabel: 'Which seasonal event',
|
||||
source: 'uo.options.seasonal',
|
||||
}),
|
||||
]
|
||||
|
||||
// ── Option sources ─────────────────────────────────────────────────────────
|
||||
@@ -1684,6 +2151,60 @@ const OPTION_SOURCES = [
|
||||
return bounded(rows, 'uo.options.decor').map((r) => ({ value: r.type, label: r.type }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uo.options.spawners',
|
||||
label: 'Spawners',
|
||||
// **The first searchable source, and the first one that had to be.** This
|
||||
// tree has 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list
|
||||
// would drop two thirds of the world and say nothing about which two thirds
|
||||
// — the failure Phase 12a named for decoration, arriving for real. Core
|
||||
// passes `q` to every source and requires it of none; this one reads it.
|
||||
searchable: true,
|
||||
description: "Spawners from the shard's own spawn files, searched by name, region or landmark.",
|
||||
async resolve({ q } = {}) {
|
||||
const rows = await shardAtlas.listSpawners({ q, limit: SPAWNER_OPTIONS })
|
||||
return rows.map((r) => ({
|
||||
// The `UniqueId`. It is the only name for one particular spawner that
|
||||
// exists off the shard — a serial is assigned when the world is built —
|
||||
// and it is what the plugin resolves a target by.
|
||||
value: r.uniqueId,
|
||||
label: r.name || r.uniqueId,
|
||||
// Where it is, because two spawners can share a name and an author
|
||||
// choosing between them is choosing a place.
|
||||
group: r.region || r.landmark || r.facet,
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uo.options.seasonal',
|
||||
label: 'Seasonal events',
|
||||
description: "ServUO's own seasonal events, the ones whose status actually does something.",
|
||||
async resolve() {
|
||||
// **Held here rather than read from the shard, and eight rather than
|
||||
// nine.** `EventType` is a compile-time enum in ServUO, so it does not
|
||||
// change under a running shard and there is nothing to import; and
|
||||
// `TreasuresOfTokuno` is left out because its `IsActive()` reads its own
|
||||
// era rather than this status, so leasing it would be a capability that
|
||||
// lies. The plugin refuses it independently.
|
||||
return SEASONAL_EVENTS.map((name) => ({ value: name, label: SEASONAL_LABELS[name] || name }))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'uo.options.items',
|
||||
label: 'Grantable items',
|
||||
description: 'What this shard is willing to hand out as a reward.',
|
||||
async resolve() {
|
||||
// Mirrored rather than read live, exactly like the lease bounds: this copy
|
||||
// is what makes a bad value a refusal on a FORM, and the shard's own copy
|
||||
// is what is true when this one is wrong. Reading it live would put an
|
||||
// authoring dropdown behind the shard being up, which §F specifically
|
||||
// says a source must not do.
|
||||
return GRANTABLE.map((g) => ({
|
||||
value: g.key,
|
||||
label: g.stackable ? `${g.label} (stacks)` : g.label,
|
||||
}))
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
@@ -1715,6 +2236,12 @@ module.exports = {
|
||||
revertOwned,
|
||||
reconcileOwned,
|
||||
MAX_LEASE_MS,
|
||||
MAX_PROP_LEASE_MS,
|
||||
MAX_SPAWN_DELAY_SEC,
|
||||
MAX_GRANT_STACK,
|
||||
GRANTABLE,
|
||||
SEASONAL_EVENTS,
|
||||
SPAWNER_OPTIONS,
|
||||
PERMANENT_STATUSES,
|
||||
webUserId,
|
||||
landmarkPoint,
|
||||
|
||||
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) 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,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
@@ -485,6 +485,13 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
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,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
@@ -500,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
landmark VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||
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;
|
||||
|
||||
-- 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
|
||||
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');
|
||||
|
||||
-- 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(
|
||||
conn,
|
||||
'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) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.uniqueId || null,
|
||||
p.x,
|
||||
p.y,
|
||||
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. */
|
||||
async function getDecorType(type) {
|
||||
const rows = await query(
|
||||
@@ -432,6 +468,7 @@ module.exports = {
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
listSpawners,
|
||||
getDecorType,
|
||||
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.
|
||||
*
|
||||
@@ -518,6 +539,7 @@ module.exports = {
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
listSpawners,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
listFacets,
|
||||
|
||||
@@ -11,16 +11,28 @@ const { secretBox } = require('../../core')
|
||||
// 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.
|
||||
//
|
||||
// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
|
||||
// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
|
||||
// This says 7 because this build speaks protocol 7: the idempotency key and the
|
||||
// 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
|
||||
// this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
|
||||
// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this
|
||||
// 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
|
||||
// from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
|
||||
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
|
||||
// matching cutover in db/schema.sql.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
|
||||
// from its shard until an admin edited the number by hand in Admin → Shard.
|
||||
//
|
||||
// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in
|
||||
// `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) {
|
||||
if (!row) {
|
||||
|
||||
@@ -67,11 +67,13 @@ test('registers exactly what module.json declares', () => {
|
||||
'uo.creature.spawn',
|
||||
'uo.decor.place',
|
||||
'uo.gate.open',
|
||||
'uo.item.grant',
|
||||
'uo.news.post',
|
||||
'uo.npc.place',
|
||||
'uo.participation.collect',
|
||||
'uo.participation.open',
|
||||
'uo.towncrier.post',
|
||||
'uo.world.save',
|
||||
],
|
||||
)
|
||||
// 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.decor',
|
||||
'uo.gate.minutes',
|
||||
'uo.rewards',
|
||||
])
|
||||
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
||||
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
||||
// on any of the rest applies cleanly and does nothing.
|
||||
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
|
||||
// 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(
|
||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||
[
|
||||
'uo.options.creatures',
|
||||
'uo.options.decor',
|
||||
'uo.options.items',
|
||||
'uo.options.landmarks',
|
||||
'uo.options.regions',
|
||||
'uo.options.seasonal',
|
||||
'uo.options.spawners',
|
||||
],
|
||||
)
|
||||
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.maxDelay, 600)
|
||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||
// under 1 MB.
|
||||
assert.equal(covetous.uniqueId, undefined)
|
||||
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
|
||||
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
|
||||
// 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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
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
|
||||
// a module declares and holds no UO knowledge, so a `uo.` dimension core knew
|
||||
// about would be a leak of this game into the engine.
|
||||
// Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead,
|
||||
// 2026-09-07): core meters what a module declares and holds no UO knowledge, so
|
||||
// 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(
|
||||
[...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) {
|
||||
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
|
||||
* 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
|
||||
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
||||
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
||||
* committed artifact under 1 MB.
|
||||
* Only the fields the site can actually use are kept. Everything to do with
|
||||
* triggering, refractory windows, proximity, sequential spawning and sounds is
|
||||
* dropped here rather than downstream, which is what keeps the parsed atlas
|
||||
* 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
|
||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||
@@ -389,6 +396,14 @@ function parsePoints(source) {
|
||||
|
||||
points.push({
|
||||
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,
|
||||
x: toInt(tagValue(block, 'X')),
|
||||
y: toInt(tagValue(block, 'Y')),
|
||||
|
||||
@@ -175,8 +175,13 @@ function hashSources(root) {
|
||||
*
|
||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||
* 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. */
|
||||
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
|
||||
// current value, `inForce()` wants to know whether the shard still has a record
|
||||
// of the hold — so a lease costs one round trip, not two.
|
||||
const getLeases = () => call('/lease')
|
||||
// **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
|
||||
// computed here and honoured there is a deadline measured against two clocks, and
|
||||
// a shard running ten minutes fast would restore a ten-minute lease the moment it
|
||||
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
|
||||
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
|
||||
const applyLease = ({ key, value, holdMs, untilMs, runId, idempotencyKey }) =>
|
||||
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
|
||||
call('/lease', {
|
||||
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
|
||||
// of core's ledger rather than the shard's memory — so a release still works after
|
||||
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
|
||||
// reverts every config lease by design) answers honestly instead of refusing.
|
||||
const releaseLease = ({ key, expected, baseline, idempotencyKey }) =>
|
||||
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
|
||||
call('/lease/release', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key,
|
||||
target,
|
||||
expected: expected == null ? undefined : String(expected),
|
||||
baseline: baseline == null ? undefined : String(baseline),
|
||||
idempotencyKey,
|
||||
@@ -324,6 +341,35 @@ const respondPage = (pageId, { message, close }) =>
|
||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||
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 = {
|
||||
TIMEOUT_MS,
|
||||
invalidateConfig,
|
||||
@@ -361,6 +407,9 @@ module.exports = {
|
||||
spawnWorld,
|
||||
ownedWorld,
|
||||
despawnWorld,
|
||||
getGrantCatalog,
|
||||
grantItem,
|
||||
saveWorld,
|
||||
adminKick,
|
||||
adminBan,
|
||||
adminUnban,
|
||||
|
||||
Reference in New Issue
Block a user