feat(events): the five world verbs an author sees (Phase 12a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / server-tests (pull_request) Successful in 30s
PR Checks / frozen-manifest (pull_request) Successful in 43s

`uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and
`uo.decor.place`, over protocol 7's one command family. Five actions because
five is what an author has; one `perform`/`revert`/`reconcile` because on the
wire they are one thing.

Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`,
`uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead,
2026-09-07). Core meters whatever dimensions a module declares and holds no UO
knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate
is priced in minutes rather than in gates: one standing all day and twelve
standing five minutes each are not the same imposition on a world.

`reconcile()` ASKS the shard, and is the one place in this file that must not
use `reconcileByBootId`. A crier line lives in shard memory, so a changed
`bootId` IS proof it is gone; a spawned creature is in the world SAVE and
survives the restart the stamp would report it lost by. Anything `world.owned`
does not list is gone -- safe only because the shard's registry and the objects
it describes are written by the same save.

Teardown reports `gone` as success and `refused` as failed. A creature a player
killed is the point of having spawned it, and a run that ended `incomplete`
because its event worked would be a report nobody could read. `refused` means
the shard denies this run ever owned the serial, so nothing will delete it
through this path and the row must land unresolved with a reason.

The atlas gains a decoration index, parsed from the shard's own
`Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree
nests two deep and a flat read would index a fraction of it while looking like
it worked. 313 distinct types. The decor verb resolves through it rather than
passing a type name through, which keeps the verb to this shard's own decoration
vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements
under 1992 different graphics, so a bare type name places the wrong thing.
`PARSER_VERSION` -> 3, so an already-imported tree is re-read.

Two things the build found in code that had already shipped:

`uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not
something the shard can build, because a creature is constructed from a ServUO
class name and `orc-brute` is not one. The atlas's `name` is the raw type token
from the spawn files, so the fix was to stop discarding the half that works.
Safe to change because Phase 12a is the source's first consumer; the file said
so when it shipped.

`uo.npc.place` could not be performed from its own required params. Both ends
refuse an oracle with neither a greeting nor a line, but both fields were
optional -- so a cross-field rule sat where no authoring form could render it.
The greeting is now `required`, which says the same thing in the contract
itself. Caught by the existing dry-run sweep, which is a better argument for
that test than anything written about it when it shipped.

605 tests pass. `swagger-fragment.json` is stale on `edge` already and this
phase adds no route, so it is left alone.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-07 01:52:15 -05:00
parent c11c130438
commit 89be9d6a4e
11 changed files with 1484 additions and 19 deletions

View File

@@ -1,8 +1,21 @@
// ── module-uo's event verbs, wave 1 ────────────────────────────────────────
// ── module-uo's event verbs ────────────────────────────────────────────────
//
// EVENTS.md §F, EVENTS_PLAN.md Phase 9. The first three actions an event author
// can put in a step that reach the game, plus the budget dimension that bounds
// one of them and the three option sources the atlas answers.
// EVENTS.md §F. Every action an event author can put in a step that reaches the
// game, the budget dimensions that bound them, the leases they may borrow, and
// the option sources the atlas answers.
//
// Three waves, and they are genuinely different kinds of thing:
//
// Phase 9 (wave 1) — `uo.broadcast`, `uo.towncrier.post`, `uo.news.post`.
// Announcements. Nothing in the world changes.
// Phase 11b — `uo.participation.*` and the one config lease. The
// shard watches, and the website borrows a value.
// Phase 12a (wave 2) — the five WORLD verbs: creatures, a boss, an oracle, a
// gate, decoration. Things appear, and this run owns them
// until teardown. See the section above `ACTIONS`.
//
// The header below is wave 1's, and its three rules still govern everything
// here — rule 1 in particular, which is why every action declares `budgetMs`.
//
// **Nothing here is new plumbing.** `uoLinkClient` has carried `adminBroadcast`,
// `postTownCrier`/`deleteTownCrier` and `postNews`/`deleteNews` since protocol
@@ -203,6 +216,49 @@ const BUDGETS = [
unit: 'broadcasts',
description: 'System messages this run may put in front of everyone online.',
},
// The world verbs (Phase 12a). These are the MODULE's dimensions, not core's
// (org lead, 2026-09-07): core meters whatever a module declares and holds no
// UO knowledge, which is the whole of what §F means by game-agnostic. Their
// defaults are the EM Program's published quotas.
//
// Every one of them is also bounded independently on the shard
// (`Bridge.EventsMax*`), which REFUSES rather than clamps. Two bounds is not
// belt and braces: an administrator raising a budget here is saying what a run
// may spend, and the operator's ceiling is saying what their world will take.
{
id: 'uo.creatures',
label: 'Creatures spawned',
unit: 'creatures',
description: 'Creatures this run may put into the world. Each one is deleted at teardown.',
},
{
id: 'uo.bosses',
label: 'Bosses spawned',
unit: 'bosses',
description: 'Enhanced creatures this run may put into the world.',
},
{
id: 'uo.npcs',
label: 'Oracle NPCs placed',
unit: 'NPCs',
description: 'Speaking NPCs this run may stand up at its venue.',
},
{
id: 'uo.decor',
label: 'Decoration placed',
unit: 'items',
description: 'Scenery items this run may place. Immovable, and removed at teardown.',
},
{
// A duration rather than a count, because one gate standing all day and
// twelve standing five minutes each are not the same imposition on a world,
// and a count would price them identically.
id: 'uo.gate.minutes',
label: 'Gate minutes',
unit: 'minutes',
description: 'Total minutes of temporary gate this run may open, across every gate.',
},
]
// ── Participation (protocol 6 part b, EVENTS_PLAN.md Phase 11b) ────────────
@@ -260,6 +316,264 @@ async function landmarkPoint(value) {
return { ok: true, map: hit.facet, x: hit.x, y: hit.y }
}
// ── The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a) ──────────────
//
// Five verbs an author sees, and ONE command family underneath them, because
// each of them ends in the same sentence: an object exists, and this run owns
// it. So `perform`, `revert` and `reconcile` are written once here and the five
// declarations below differ only in what they validate and what they send.
//
// **`revert` and `reconcile` are shared but not interchangeable with the rest of
// this file's**, and the difference is the phase's headline. A crier line and a
// news article live in shard memory, so a changed `bootId` IS proof they are
// gone and `reconcileByBootId` can answer without asking. A spawned creature is
// in the world SAVE. It survives the restart the boot stamp would report it lost
// by, so the only honest answer is to ask the shard what it still holds — which
// is what `world.owned` is for, and why it prunes as it walks.
/** The ledger kind every world verb files its serials under. */
const OWNED_KIND = 'world'
// Mirrors of the shard's own default ceilings (`Bridge.EventsMax*`), pre-checked
// here so an over-large step is a refusal a DRY RUN can show the author rather
// than a 400 arriving mid-run. The shard's are authoritative and an operator may
// set them lower, in which case its refusal is the one that lands \u2014 which is
// correct: these are a courtesy, not the bound.
const MAX_CREATURES = 30
const MAX_BOSSES = 4
const MAX_NPCS = 5
const MAX_DECOR = 60
/** The widest scatter an author may ask for, mirroring the shard's own bound. */
const MAX_SPREAD = 40
/** How much harder than a normal creature a boss may be made. */
const MAX_BOSS_MULTIPLIER = 10
/** How many keyword lines one oracle answers to. */
const MAX_ORACLE_LINES = 5
/** The longest a temporary gate may stand, in minutes. */
const MAX_GATE_MINUTES = 240
/** Read a count param, or a refusal an author can act on. */
function counted(raw, ceiling, what) {
const count = raw === undefined || raw === null || raw === '' ? 1 : Number(raw)
if (!Number.isInteger(count) || count < 1 || count > ceiling) {
return { ok: false, error: `place 1 to ${ceiling} ${what} at a time, and "${raw}" is not that` }
}
return { ok: true, count }
}
/** Read an optional positive-integer param (a hue, a spread), or a refusal. */
function optionalInt(raw, { max, name }) {
if (raw === undefined || raw === null || raw === '') return { ok: true, value: undefined }
const value = Number(raw)
if (!Number.isInteger(value) || value < 0 || (max !== undefined && value > max)) {
return { ok: false, error: `"${raw}" is not a ${name} this shard will take` }
}
return { ok: true, value }
}
/** Read an optional multiplier, or a refusal. */
function multiplier(raw, name) {
if (raw === undefined || raw === null || raw === '') return { ok: true, value: undefined }
const value = Number(raw)
if (!Number.isFinite(value) || value < 1 || value > MAX_BOSS_MULTIPLIER) {
return {
ok: false,
error: `a ${name} is 1 to ${MAX_BOSS_MULTIPLIER} times normal, and "${raw}" is not`,
}
}
return { ok: true, value }
}
/**
* Parse an oracle's dialogue out of one textarea.
*
* One row per line, `keywords = what it says`, split on the FIRST `=` so the
* answer may contain one and the keywords may not:
*
* fire, flame = The flame you seek burns beneath the keep.
* gate = A gate will open at dusk, by the bank.
*
* A textarea rather than five pairs of fields because the action param types are
* scalars (`string`, `int`, `float`, `boolean`, `datetime`, `url`) and there is
* no array among them — and because §G's whole claim about this verb is that it
* is a web form. Ten numbered fields would be a worse one than a text box.
*/
function oracleLines(raw) {
const rows = []
const text = raw === undefined || raw === null ? '' : String(raw)
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (trimmed === '') continue
const cut = trimmed.indexOf('=')
if (cut < 1) {
return { ok: false, error: `"${trimmed}" is not "keywords = what to say"` }
}
const keywords = trimmed
.slice(0, cut)
.split(',')
.map((word) => word.trim())
.filter(Boolean)
const say = trimmed.slice(cut + 1).trim()
if (!keywords.length || !say) {
return { ok: false, error: `"${trimmed}" needs both a keyword and something to say` }
}
rows.push({ keywords: keywords.join(','), text: say })
}
if (rows.length > MAX_ORACLE_LINES) {
return {
ok: false,
error: `an oracle answers to at most ${MAX_ORACLE_LINES} things, and this gives ${rows.length}`,
}
}
return { ok: true, rows }
}
/**
* Place something, and file every serial the shard hands back.
*
* One resource per SERIAL rather than one per call, so a group half of which a
* player killed reconciles per creature instead of all-or-nothing. The payload
* carries what it was, because a ledger row reading "this run owned something
* and it is gone" is worth less to an operator than one naming the orc.
*/
async function placeOwned({ runId, idempotencyKey, what, body }) {
const result = await uoLinkClient.spawnWorld({
runId: String(runId),
what,
idempotencyKey,
...body,
})
if (!result.ok) return sidecarFailure(result, `${what} placement`)
const serials = Array.isArray(result.data && result.data.serials) ? result.data.serials : []
return {
ok: true,
resources: serials.map((serial) => ({
kind: OWNED_KIND,
ref: String(serial),
payload: { runId: String(runId), what, type: body.type || what, name: body.name || null },
})),
}
}
/**
* Give back what this step placed.
*
* `gone` is not reported at all, deliberately: a creature a player killed is the
* point of having spawned it, and §L already says "gone, and that is fine" is a
* successful revert. `refused` IS reported, as `failed`, because it means the
* shard denies this run ever owned that serial — nothing will ever delete it
* through this path, so the row must land unresolved with a reason rather than
* be quietly marked reverted.
*/
async function revertOwned({ runId, resources, idempotencyKey }) {
const result = await uoLinkClient.despawnWorld({
runId: String(runId),
serials: resources.map((resource) => resource.ref),
idempotencyKey,
})
if (!result.ok) return { ok: false, error: sidecarReason(result, 'despawn') }
const refused = Array.isArray(result.data && result.data.refused)
? result.data.refused.map(String)
: []
return refused.length ? { ok: true, failed: refused } : { ok: true }
}
/**
* Ask the shard what this run still owns.
*
* NOT `reconcileByBootId`. See the section header: these resources are in the
* world save and survive the restart the boot stamp would report them lost by.
*
* Anything the shard does not list is gone, and that is a safe reading only
* because the registry and the objects it describes are written by the SAME
* world save — they cannot get out of step with each other. An unreachable or
* refusing shard has said nothing, so the whole group is left alone.
*/
async function reconcileOwned({ runId, resources }) {
const result = await uoLinkClient.ownedWorld({ runId: String(runId) })
if (!result.ok) return { ok: false, error: sidecarReason(result, 'owned') }
const rows = Array.isArray(result.data && result.data.owned) ? result.data.owned : []
const held = new Set(rows.map((row) => String(row.serial)))
return { ok: true, inForce: resources.filter((r) => held.has(r.ref)).map((r) => r.ref) }
}
/** The three fields every world verb shares, so five declarations cannot drift apart. */
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
// deliberately — which is the right consent for a scheduled, unattended
// change to a live world.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
revert: revertOwned,
reconcile: reconcileOwned,
}
/**
* The body creatures and bosses share: a resolved place, a validated type, and
* the optional dressing. The boss verb adds its multipliers on top.
*
* The creature is named by its ServUO TYPE, which is what
* `uo.options.creatures` now answers with \u2014 see the option source. The atlas
* slug would be unusable here: the shard constructs from a class name, and a
* value an author picks that the shard cannot act on is not a value.
*/
async function creatureBody(params, ceiling) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, error: place.error }
const howMany = counted(params.count, ceiling, 'creatures')
if (!howMany.ok) return { ok: false, error: howMany.error }
const type = String(params.creature || '').trim()
if (!type) return { ok: false, error: 'pick a creature' }
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, error: hue.error }
const spread = optionalInt(params.spread, { max: MAX_SPREAD, name: 'spread' })
if (!spread.ok) return { ok: false, error: spread.error }
return {
ok: true,
value: {
map: place.map,
x: place.x,
y: place.y,
count: howMany.count,
type,
name: String(params.name || '').trim() || undefined,
hue: hue.value,
spread: spread.value,
},
}
}
/** The `place` param, shared by every verb: where in the world this happens. */
const PLACE_PARAM = {
name: 'place',
type: 'string',
required: true,
example: 'Felucca/Britain',
source: 'uo.options.landmarks',
description: 'Where this happens.',
}
// ── Actions ────────────────────────────────────────────────────────────────
const ACTIONS = [
@@ -671,7 +985,6 @@ const ACTIONS = [
return { ok: true, inForce }
},
},
{
id: 'uo.participation.collect',
label: 'Record who took part',
@@ -728,6 +1041,416 @@ const ACTIONS = [
}
},
},
{
id: 'uo.creature.spawn',
label: 'Spawn creatures',
description:
"Puts creatures into the world at a place you choose, optionally renamed and recoloured. Each one is recorded against this run and deleted at teardown \u2014 and a creature players kill in the meantime is an ordinary outcome, not a failure.",
...OWNED_COMMON,
cost: (p) => ({ 'uo.creatures': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'creature',
type: 'string',
required: true,
example: 'Orc',
source: 'uo.options.creatures',
description: "Which creature. The list is what this shard's own spawners actually use.",
},
{ name: 'count', type: 'int', required: true, example: 8, description: 'How many.' },
{
name: 'name',
type: 'string',
required: false,
example: 'Rotting Orc',
description: 'What they are called. Left out, the creature keeps its own name.',
},
{
name: 'hue',
type: 'int',
required: false,
example: 1157,
description: 'UO colour id. Left out, the creature keeps its own colour.',
},
{
name: 'spread',
type: 'int',
required: false,
example: 6,
description: `How many tiles to scatter them across, up to ${MAX_SPREAD}. Left out, they arrive on one tile.`,
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const body = await creatureBody(params, MAX_CREATURES)
if (!body.ok) return { ok: false, retry: false, error: body.error }
if (verify) return { ok: true }
return placeOwned({ runId, idempotencyKey, what: 'creature', body: body.value })
},
},
{
id: 'uo.boss.spawn',
label: 'Spawn a boss',
description:
"An ordinary creature made harder and given a name \u2014 EVENTS.md's \"enhanced regular mob\". The event owns what it created and never touches a creature it did not; there is no verb here that reaches an existing boss.",
...OWNED_COMMON,
cost: (p) => ({ 'uo.bosses': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'creature',
type: 'string',
required: true,
example: 'OrcCaptain',
source: 'uo.options.creatures',
description: 'Which creature to build it from.',
},
{
name: 'name',
type: 'string',
required: true,
example: 'Gruk the Unbroken',
description: 'What it is called. Required here \u2014 an unnamed boss is just a hard orc.',
},
{ name: 'count', type: 'int', required: false, example: 1, description: 'How many.' },
{
name: 'hitsMultiplier',
type: 'float',
required: false,
example: 3,
description: `How much tougher than normal, up to ${MAX_BOSS_MULTIPLIER}.`,
},
{
name: 'damageMultiplier',
type: 'float',
required: false,
example: 1.5,
description: `How much harder it hits, up to ${MAX_BOSS_MULTIPLIER}.`,
},
{
name: 'statMultiplier',
type: 'float',
required: false,
example: 2,
description: `How much its strength, dexterity and intelligence are raised, up to ${MAX_BOSS_MULTIPLIER}.`,
},
{ name: 'hue', type: 'int', required: false, example: 1175, description: 'UO colour id.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const body = await creatureBody(params, MAX_BOSSES)
if (!body.ok) return { ok: false, retry: false, error: body.error }
if (!String(params.name || '').trim()) {
return { ok: false, retry: false, error: 'a boss needs a name' }
}
for (const field of ['hitsMultiplier', 'damageMultiplier', 'statMultiplier']) {
const parsed = multiplier(params[field], field.replace('Multiplier', ' multiplier'))
if (!parsed.ok) return { ok: false, retry: false, error: parsed.error }
if (parsed.value !== undefined) body.value[field] = parsed.value
}
if (verify) return { ok: true }
return placeOwned({ runId, idempotencyKey, what: 'boss', body: body.value })
},
},
{
id: 'uo.npc.place',
label: 'Stand up an oracle',
description:
'A speaking NPC that greets players who come near and answers to words you choose. It cannot be killed, looted or moved, so it is still where this run left it when teardown comes to collect it.',
...OWNED_COMMON,
cost: (p) => ({ 'uo.npcs': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'name',
type: 'string',
required: true,
example: 'Marisa the Seer',
description: 'What it is called.',
},
{
name: 'title',
type: 'string',
required: false,
example: 'the seer',
description: 'A title shown under the name.',
},
{
// **Required, and it was optional until the dry-run sweep caught it.**
// An oracle with neither a greeting nor a line stands there in silence,
// which both ends refuse — so with both fields optional the verb could
// not be performed from its own required params, and no authoring form
// could render it as valid either. A cross-field "at least one of these"
// rule is the wrong shape for a declaration core reads as data; making
// the greeting required says the same thing in the contract itself.
name: 'greeting',
type: 'string',
required: true,
example: 'You have the look of someone with a question.',
description: 'Said once to each player who comes near.',
},
{
name: 'lines',
type: 'string',
required: false,
example: 'fire, flame = The flame you seek burns beneath the keep.',
description: `One per line, "keywords = what to say", up to ${MAX_ORACLE_LINES}. Keywords are separated by commas and matched anywhere in what a player says.`,
},
{
name: 'sex',
type: 'string',
required: false,
example: 'female',
description: '"female" or "male". Left out, male.',
},
{ name: 'count', type: 'int', required: false, example: 1, description: 'How many.' },
{ name: 'hue', type: 'int', required: false, example: 1002, description: 'Skin colour id.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, retry: false, error: place.error }
const howMany = counted(params.count, MAX_NPCS, 'oracles')
if (!howMany.ok) return { ok: false, retry: false, error: howMany.error }
const name = String(params.name || '').trim()
if (!name) return { ok: false, retry: false, error: 'an oracle needs a name' }
const lines = oracleLines(params.lines)
if (!lines.ok) return { ok: false, retry: false, error: lines.error }
const greeting = String(params.greeting || '').trim()
// Refused HERE as well as on the shard, because this is the one the author
// can act on: a dry run says so instead of the step failing mid-run
// against a rule nobody had seen. `required` catches an absent field;
// this catches a field holding nothing but spaces.
if (!greeting) {
return {
ok: false,
retry: false,
error: 'an oracle with nothing to say would stand there in silence',
}
}
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
if (verify) return { ok: true }
return placeOwned({
runId,
idempotencyKey,
what: 'npc',
body: {
map: place.map,
x: place.x,
y: place.y,
count: howMany.count,
name,
title: String(params.title || '').trim() || undefined,
sex: String(params.sex || '').trim().toLowerCase() === 'female' ? 'female' : undefined,
hue: hue.value,
greeting,
lines: lines.rows,
},
})
},
},
{
id: 'uo.gate.open',
label: 'Open a gate',
description:
'A moongate from one place to another, for a bounded time. The shard closes it when the time is up whether or not the website is ever heard from again, so a run whose engine dies leaves a world that comes back early rather than one stuck open.',
...OWNED_COMMON,
cost: (p) => ({ 'uo.gate.minutes': Number(p.durationMinutes) || 0 }),
params: [
{ ...PLACE_PARAM, description: 'Where the gate stands.' },
{
name: 'destination',
type: 'string',
required: true,
example: 'Felucca/Yew',
source: 'uo.options.landmarks',
description: 'Where it leads.',
},
{
name: 'durationMinutes',
type: 'int',
required: true,
example: 120,
description: `How long it stands, up to ${MAX_GATE_MINUTES} minutes.`,
},
{
name: 'name',
type: 'string',
required: false,
example: 'to the gathering',
description: 'What it is called when a player looks at it.',
},
{ name: 'hue', type: 'int', required: false, example: 1153, description: 'UO colour id.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, retry: false, error: place.error }
const target = await landmarkPoint(params.destination)
if (!target.ok) return { ok: false, retry: false, error: target.error }
const minutes = Number(params.durationMinutes)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_GATE_MINUTES) {
return {
ok: false,
retry: false,
error: `a gate stands 1 to ${MAX_GATE_MINUTES} minutes, and "${params.durationMinutes}" is not that`,
}
}
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
if (verify) return { ok: true }
return placeOwned({
runId,
idempotencyKey,
what: 'gate',
body: {
map: place.map,
x: place.x,
y: place.y,
// A duration, never an absolute time. An absolute deadline computed
// here and honoured there is measured against two clocks, and a shard
// ten minutes fast would collect the gate the instant it opened \u2014
// the same argument protocol 6 made for a lease's `holdMs`.
holdMs: minutes * 60_000,
name: String(params.name || '').trim() || undefined,
hue: hue.value,
target: { map: target.map, x: target.x, y: target.y },
},
})
},
},
{
id: 'uo.decor.place',
label: 'Place decoration',
description:
"Scenery for the venue, from what this shard already calls decoration. Placed immovable so it is still there at teardown, and removed then. Containers are refused: deleting one would delete whatever a player had left inside it.",
...OWNED_COMMON,
cost: (p) => ({ 'uo.decor': Number(p.count) || 1 }),
params: [
PLACE_PARAM,
{
name: 'item',
type: 'string',
required: true,
example: 'Brazier',
source: 'uo.options.decor',
description: "Which item. The list comes from this shard's own decoration files.",
},
{ name: 'count', type: 'int', required: true, example: 6, description: 'How many.' },
{
name: 'hue',
type: 'int',
required: false,
example: 1157,
description: 'UO colour id. Left out, the item keeps its own colour.',
},
{
name: 'spread',
type: 'int',
required: false,
example: 4,
description: `How many tiles to scatter them across, up to ${MAX_SPREAD}.`,
},
{
name: 'name',
type: 'string',
required: false,
example: 'a festival brazier',
description: 'What it is called when a player looks at it.',
},
],
async perform({ runId, idempotencyKey, params, verify }) {
const place = await landmarkPoint(params.place)
if (!place.ok) return { ok: false, retry: false, error: place.error }
const howMany = counted(params.count, MAX_DECOR, 'items')
if (!howMany.ok) return { ok: false, retry: false, error: howMany.error }
const type = String(params.item || '').trim()
if (!type) return { ok: false, retry: false, error: 'pick something to place' }
// Resolved through the atlas rather than passed straight through, which
// does two things at once.
//
// It keeps the verb to the vocabulary this shard's own decoration files
// use \u2014 a tighter boundary than "any item that is not a container", and
// the one the decision actually took.
//
// And it fetches the ITEM ID, which some types cannot do without. Measured
// on ServUO 57.4: `Static` accounts for 5031 of the tree's decoration
// placements under **1992 different graphics**, because for that class the
// graphic IS the identity \u2014 a bare `new Static()` is not the switch or the
// paving stone the author picked, it is whatever the class defaults to.
// 131 of the 313 types carry more than one id (a door has one per facing).
const known = await shardAtlas.getDecorType(type)
if (!known) {
return {
ok: false,
retry: false,
error: `this shard's decoration files never mention "${type}"`,
}
}
const hue = optionalInt(params.hue, { name: 'colour' })
if (!hue.ok) return { ok: false, retry: false, error: hue.error }
const spread = optionalInt(params.spread, { max: MAX_SPREAD, name: 'spread' })
if (!spread.ok) return { ok: false, retry: false, error: spread.error }
if (verify) return { ok: true }
return placeOwned({
runId,
idempotencyKey,
what: 'decor',
body: {
map: place.map,
x: place.x,
y: place.y,
count: howMany.count,
type: known.type,
itemId: known.itemId || undefined,
hue: hue.value,
spread: spread.value,
name: String(params.name || '').trim() || undefined,
},
})
},
},
]
/**
@@ -932,10 +1655,33 @@ const OPTION_SOURCES = [
label: 'Creatures',
description: 'Creature types the shard actually spawns, from the spawn atlas.',
async resolve() {
// The slug is unique by construction, so unlike a place a creature needs no
// qualifier: it is the same type wherever it spawns.
// **The value is the ServUO TYPE NAME, not the atlas slug** (Phase 12a).
//
// Wave 1 declared this source before anything consumed it and used the
// slug, which is unique and stable and cannot be acted on: the shard
// constructs a creature from a class name, and `orccaptain` is not one.
// The atlas's `name` IS the type token, preserved verbatim from the spawn
// files (`displayName` picks the best-attested spelling of the raw token),
// so no lookup table is needed \u2014 only the decision to stop throwing the
// usable half away.
//
// Safe to change because Phase 12a is this source's first consumer; the
// file said so when it shipped.
const { creatures } = await shardAtlas.searchCreatures({ limit: MAX_OPTIONS })
return creatures.map((c) => ({ value: c.slug, label: c.name }))
return creatures.map((c) => ({ value: c.name, label: c.name }))
},
},
{
id: 'uo.options.decor',
label: 'Decoration',
description: "Item types this shard already uses as scenery, most-used first.",
async resolve() {
// From `Data/Decoration/**/*.cfg` at atlas-import time, so this is the
// operator's own decoration vocabulary rather than a list curated by us \u2014
// and, like every source here, it resolves with the shard down.
const rows = await shardAtlas.listDecorTypes()
return bounded(rows, 'uo.options.decor').map((r) => ({ value: r.type, label: r.type }))
},
},
]
@@ -956,6 +1702,18 @@ module.exports = {
MAX_NEWS_BODY,
MAX_OPTIONS,
MAX_AREA_RADIUS,
MAX_CREATURES,
MAX_BOSSES,
MAX_NPCS,
MAX_DECOR,
MAX_SPREAD,
MAX_BOSS_MULTIPLIER,
MAX_ORACLE_LINES,
MAX_GATE_MINUTES,
OWNED_KIND,
oracleLines,
revertOwned,
reconcileOwned,
MAX_LEASE_MS,
PERMANENT_STATUSES,
webUserId,