feat(events): the UO half of the Event System, and the core pin comes home (Phase 16b cutover, 3 of 6) #34

Merged
whitlocktech merged 14 commits from chore/events-cutover-repin into main 2026-09-10 01:23:39 +00:00
12 changed files with 1122 additions and 29 deletions
Showing only changes of commit c289586a3d - Show all commits

View File

@@ -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 shards own allowlist.',
},
{
name: 'amount',
type: 'int',
required: true,
example: 500,
description: `How many each. Stackable items only; ${MAX_GRANT_STACK} at most.`,
},
{
name: 'where',
type: 'string',
required: false,
example: 'backpack',
description: 'backpack or bank. Left out, the backpack.',
},
{
name: 'hue',
type: 'int',
required: false,
example: 1157,
description: 'UO colour id, for a commemorative reward.',
},
{
name: 'name',
type: 'string',
required: false,
example: 'a champions cloak',
description: 'What it is called when a player looks at it.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const item = String(params.item || '').trim()
if (!item) return { ok: false, retry: false, error: 'pick something to grant' }
const known = GRANTABLE.find((g) => g.key === item)
if (!known) {
return { ok: false, retry: false, error: `this shard does not grant "${item}"` }
}
// Its own check rather than `counted()`: that one's refusal reads "place 1
// to N items at a time", which is the spawn verbs' sentence and wrong here
// — nothing is being placed. The bound is the same shape and mirrors
// `Bridge.EventsMaxGrantStack`.
const amount = Number(params.amount)
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_GRANT_STACK) {
return {
ok: false,
retry: false,
error: `grant 1 to ${MAX_GRANT_STACK} at a time, and "${params.amount}" is not that`,
}
}
if (!known.stackable && amount > 1) {
return {
ok: false,
retry: false,
error: `${known.label} does not stack, so it can only be granted one at a time`,
}
}
const hue = optionalInt(params.hue, { max: 65535, name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
const where = String(params.where || 'backpack').trim().toLowerCase()
if (where !== 'backpack' && where !== 'bank') {
return { ok: false, retry: false, error: `"${params.where}" is not backpack or bank` }
}
// **The dry run stops here, and it has checked everything it can.** What
// it deliberately does not do is ask the shard who is present: a verify
// that failed because a run has no ledger open would refuse every grant
// authored before its own event ran, which is every grant.
if (verify) return { ok: true }
const result = await uoLinkClient.grantItem({
runId,
item,
amount,
hue: hue.value,
name: params.name ? String(params.name).slice(0, 40) : undefined,
where,
idempotencyKey,
})
// **Retryable, and protocol 6 is the whole reason.** §G called a grant
// un-retryable because a lost acknowledgement and a grant that never
// applied were the same event — exactly the argument that made
// `uo.broadcast` answer `retry: false` in Phase 9. An `idempotencyKey`
// closes that: a repeat is answered by the original reply, so a retried
// grant cannot be one winner receiving two.
if (!result.ok) return sidecarFailure(result, 'grant')
const granted = Number(result.data?.granted) || 0
const missed = (result.data && result.data.missed) || []
// A grant that reached nobody is a SUCCESS, and the distinction is the
// shard's: a run it was never told to count is a 404 above, while a run
// whose ledger is open and empty answers 200 with `granted: 0`. An event
// nobody attended still happened, and retrying against the same empty
// ledger would pause a run for ever.
return {
ok: true,
detail: { granted, missed: missed.length, ...(missed.length ? { why: missed.slice(0, 10) } : {}) },
}
},
},
{
id: 'uo.world.save',
label: 'Save the world',
description:
'Ask the shard to write a world save. Useful as a phase boundary — the point after which what the event has done so far survives a crash.',
// Nothing is created and nothing is altered; the world is written to disk.
// `inspect` would be a lie (it stops the world for a moment) and `change`
// is what that is.
risk: 'change',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [],
async perform({ idempotencyKey, verify }) {
if (verify) return { ok: true }
const result = await uoLinkClient.saveWorld({ idempotencyKey })
// 429 is the shard's save rate limit, and it is the one refusal on this
// plane that waiting fixes: the same request succeeds once the interval
// passes. It is not in `PERMANENT_STATUSES`, so `sidecarFailure`
// classifies it retry without needing an arm of its own — which is what
// makes a phase boundary retried rather than abandoned.
if (!result.ok) return sidecarFailure(result, 'world save')
// What actually happened rides `world.save.before`/`after` on the event
// stream. This step reports only that the save was started, because that
// is the only thing the reply knows.
return { ok: true, detail: { started: true } }
},
},
]
/**
@@ -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,

View File

@@ -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');

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)
})

View File

@@ -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')

View 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' })
})

View File

@@ -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')),

View File

@@ -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) {

View File

@@ -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,