diff --git a/server/config/uoEventActions.js b/server/config/uoEventActions.js index eef19e1..674cedb 100644 --- a/server/config/uoEventActions.js +++ b/server/config/uoEventActions.js @@ -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 `#` 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, diff --git a/server/db/schema.sql b/server/db/schema.sql index 0fb9a16..862de6f 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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: ` 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'); diff --git a/server/model/shardAtlas/shardAtlas.db.js b/server/model/shardAtlas/shardAtlas.db.js index 0380d10..f411016 100644 --- a/server/model/shardAtlas/shardAtlas.db.js +++ b/server/model/shardAtlas/shardAtlas.db.js @@ -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, } diff --git a/server/model/shardAtlas/shardAtlas.model.js b/server/model/shardAtlas/shardAtlas.model.js index 20423cd..fb40b84 100644 --- a/server/model/shardAtlas/shardAtlas.model.js +++ b/server/model/shardAtlas/shardAtlas.model.js @@ -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, diff --git a/server/model/uoLinkConfig/uoLinkConfig.model.js b/server/model/uoLinkConfig/uoLinkConfig.model.js index fe45ddd..39d82a3 100644 --- a/server/model/uoLinkConfig/uoLinkConfig.model.js +++ b/server/model/uoLinkConfig/uoLinkConfig.model.js @@ -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) { diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 7efa3f3..523c991 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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) diff --git a/server/test/spawnAtlas.parse.test.js b/server/test/spawnAtlas.parse.test.js index ffe3de6..5db10a6 100644 --- a/server/test/spawnAtlas.parse.test.js +++ b/server/test/spawnAtlas.parse.test.js @@ -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) }) diff --git a/server/test/uoEventActions.test.js b/server/test/uoEventActions.test.js index fbd3b9a..1067166 100644 --- a/server/test/uoEventActions.test.js +++ b/server/test/uoEventActions.test.js @@ -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') diff --git a/server/test/uoEventBorrowed.test.js b/server/test/uoEventBorrowed.test.js new file mode 100644 index 0000000..d620c2a --- /dev/null +++ b/server/test/uoEventBorrowed.test.js @@ -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 `#` 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' }) +}) diff --git a/server/utils/spawnAtlasParse.js b/server/utils/spawnAtlasParse.js index 6bdd393..d7d852f 100644 --- a/server/utils/spawnAtlasParse.js +++ b/server/utils/spawnAtlasParse.js @@ -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 ``, 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')), diff --git a/server/utils/spawnAtlasSource.js b/server/utils/spawnAtlasSource.js index 6e19e73..78ef0b2 100644 --- a/server/utils/spawnAtlasSource.js +++ b/server/utils/spawnAtlasSource.js @@ -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) { diff --git a/server/utils/uoLinkClient.js b/server/utils/uoLinkClient.js index aeff836..16d4cd6 100644 --- a/server/utils/uoLinkClient.js +++ b/server/utils/uoLinkClient.js @@ -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,