feat(events): the five world verbs an author sees (Phase 12a)
`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:
@@ -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,
|
||||
|
||||
@@ -544,6 +544,26 @@ CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Item types this shard uses as decoration, from Data/Decoration/**/*.cfg.
|
||||
--
|
||||
-- Import-owned like every other shard_* atlas table. It exists so the events
|
||||
-- decoration verb can offer an author a dropdown of what THIS shard already
|
||||
-- calls scenery, rather than a list of item types curated by us: a shard with
|
||||
-- custom decoration gets its own, and the list resolves with the shard offline
|
||||
-- because it came out of the tree at import time.
|
||||
--
|
||||
-- `item_id` is a preview, not an identity. A type appears under as many item
|
||||
-- ids as it has facings or variants (a BarredMetalDoor under eight), and the
|
||||
-- first one seen is kept; the plugin constructs from the TYPE NAME and picks
|
||||
-- its own graphic. `uses` is how many times the shard's own decoration reaches
|
||||
-- for the type, which is the only ordering signal available that means anything.
|
||||
CREATE TABLE IF NOT EXISTS shard_decor_types (
|
||||
type VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||
item_id INT NOT NULL DEFAULT 0,
|
||||
uses INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_decor_types_uses (uses)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||
|
||||
@@ -16,6 +16,7 @@ const ATLAS_TABLES = [
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
'shard_decor_types',
|
||||
]
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
@@ -103,6 +104,16 @@ async function replaceAtlas(atlas, art = {}) {
|
||||
]),
|
||||
)
|
||||
|
||||
// Optional: a tree with no Data/Decoration leaves this empty rather than
|
||||
// failing the import, and the decoration verb then simply has nothing to
|
||||
// offer. `?? []` rather than a guard, so an atlas built by an older parser
|
||||
// (no `decor` key at all) reloads cleanly instead of throwing here.
|
||||
counts.decor = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_decor_types (type, item_id, uses) VALUES (?,?,?)',
|
||||
(atlas.decor ?? []).map((d) => [d.type, d.itemId ?? 0, d.uses ?? 0]),
|
||||
)
|
||||
|
||||
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||
// for a multi-row insert. Safe because this transaction just emptied the
|
||||
@@ -357,6 +368,38 @@ function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every decoration type this shard uses, most-used first.
|
||||
*
|
||||
* Ordered by `uses` because a dropdown of 313 types needs the ones the shard
|
||||
* actually reaches for at the top; the alphabetical tiebreak keeps the order
|
||||
* stable across imports, which matters for a form an author scrolls.
|
||||
*/
|
||||
function listDecorTypes({ q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('type LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT type, item_id, uses
|
||||
FROM shard_decor_types
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY uses DESC, type ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** One decoration type, or nothing when this shard's files never name it. */
|
||||
async function getDecorType(type) {
|
||||
const rows = await query(
|
||||
'SELECT type, item_id, uses FROM shard_decor_types WHERE type = ?',
|
||||
[type],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
@@ -388,5 +431,7 @@ module.exports = {
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
}
|
||||
|
||||
@@ -403,6 +403,38 @@ async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoration types, shaped for a dropdown.
|
||||
*
|
||||
* `type` is both the value and the label: it is the ServUO class name and it is
|
||||
* what the plugin constructs from, so showing the author anything else would
|
||||
* put a name on the screen that does not appear in the refusal if the shard
|
||||
* declines it.
|
||||
*/
|
||||
async function listDecorTypes(opts = {}) {
|
||||
const rows = await db.listDecorTypes(opts)
|
||||
return rows.map((r) => ({
|
||||
type: r.type,
|
||||
itemId: Number(r.item_id) || 0,
|
||||
uses: Number(r.uses) || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* One decoration type, or null.
|
||||
*
|
||||
* The events decoration verb resolves through this rather than passing a type
|
||||
* name straight through, which does two things at once: it fetches the item id
|
||||
* the graphic-holder classes need, and it keeps the verb to the vocabulary this
|
||||
* shard's own decoration files use. A type the atlas has never seen is refused
|
||||
* here rather than constructed there.
|
||||
*/
|
||||
async function getDecorType(type) {
|
||||
const row = await db.getDecorType(String(type == null ? '' : type).trim())
|
||||
if (!row) return null
|
||||
return { type: row.type, itemId: Number(row.item_id) || 0, uses: Number(row.uses) || 0 }
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
@@ -485,6 +517,8 @@ module.exports = {
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
|
||||
@@ -62,21 +62,42 @@ test('registers exactly what module.json declares', () => {
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventActions.map((a) => a.id).sort(),
|
||||
[
|
||||
'uo.boss.spawn',
|
||||
'uo.broadcast',
|
||||
'uo.creature.spawn',
|
||||
'uo.decor.place',
|
||||
'uo.gate.open',
|
||||
'uo.news.post',
|
||||
'uo.npc.place',
|
||||
'uo.participation.collect',
|
||||
'uo.participation.open',
|
||||
'uo.towncrier.post',
|
||||
],
|
||||
)
|
||||
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), ['uo.broadcasts'])
|
||||
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
|
||||
// 2026-09-07): core meters whatever a module declares and knows nothing about
|
||||
// Ultima Online. Asserted as an ordered list because the order is the order
|
||||
// an author meets them in a cap meter.
|
||||
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [
|
||||
'uo.broadcasts',
|
||||
'uo.creatures',
|
||||
'uo.bosses',
|
||||
'uo.npcs',
|
||||
'uo.decor',
|
||||
'uo.gate.minutes',
|
||||
])
|
||||
// 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'])
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
|
||||
[
|
||||
'uo.options.creatures',
|
||||
'uo.options.decor',
|
||||
'uo.options.landmarks',
|
||||
'uo.options.regions',
|
||||
],
|
||||
)
|
||||
assert.ok(api.record.streams.length > 0)
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
parseDecoration,
|
||||
decodeEntities,
|
||||
} = require('../utils/spawnAtlasParse')
|
||||
|
||||
@@ -599,3 +600,49 @@ test('parsePoints: DelayInSec decides the unit, and both come out in seconds', (
|
||||
assert.equal(seconds.minDelay, 5)
|
||||
assert.equal(seconds.maxDelay, 10)
|
||||
})
|
||||
|
||||
// ── parseDecoration (Phase 12a) ───────────────────────────────
|
||||
|
||||
test('parseDecoration: reads the type off each header and ignores the placements', () => {
|
||||
const rows = parseDecoration(`# switch
|
||||
Static 0x108F
|
||||
5552 1864 11
|
||||
5399 1875 17
|
||||
|
||||
# crate
|
||||
LargeCrate 0x0E3C
|
||||
5408 607 45
|
||||
`)
|
||||
assert.deepEqual(rows, [
|
||||
{ type: 'Static', itemId: 0x108f },
|
||||
{ type: 'LargeCrate', itemId: 0x0e3c },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseDecoration: a parenthesised property list is not part of the type', () => {
|
||||
// These are the shard's own decoration details — which way a door faces, what
|
||||
// hue a banner is — and an event author is choosing neither. Only the class
|
||||
// name is, because that is what the plugin constructs from.
|
||||
assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [
|
||||
{ type: 'AnkhNorth', itemId: 4 },
|
||||
])
|
||||
assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [
|
||||
{ type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => {
|
||||
// The real trap in this format: a coordinate line starts with a digit OR a
|
||||
// minus, so "not a comment" is not the test. A z of -12 is ordinary in every
|
||||
// dungeon file in the tree.
|
||||
assert.deepEqual(parseDecoration(`Static 0x07A4
|
||||
5558 1826 -12
|
||||
-5 -5 -5
|
||||
`), [{ type: 'Static', itemId: 0x07a4 }])
|
||||
})
|
||||
|
||||
test('parseDecoration: empty, comment-only and absent input all yield nothing', () => {
|
||||
assert.deepEqual(parseDecoration(''), [])
|
||||
assert.deepEqual(parseDecoration(null), [])
|
||||
assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), [])
|
||||
})
|
||||
|
||||
@@ -39,6 +39,21 @@ function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {})
|
||||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||
|
||||
// Decoration, NESTED, because the real tree nests two deep in places
|
||||
// (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a
|
||||
// fraction of it while looking like it worked.
|
||||
fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Decoration', 'top.cfg'),
|
||||
'# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n',
|
||||
'utf8',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'),
|
||||
'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
for (const facet of facets) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Spawns', `${facet}.xml`),
|
||||
@@ -397,3 +412,45 @@ test('refresh: an explicit path overrides the configured one', async () => {
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(result.addedFacets, ['Override'])
|
||||
})
|
||||
|
||||
// ── The decoration index (Phase 12a) ────────────────────────
|
||||
|
||||
test('decoration is read recursively and rolled up per type', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-'))
|
||||
try {
|
||||
writeTree(root)
|
||||
const atlas = buildAtlas(root)
|
||||
|
||||
// Sorted by type, and `uses` counts every header line across the whole tree
|
||||
// — the nested file's Brazier is the second use of the same type, not a
|
||||
// second type.
|
||||
assert.deepEqual(atlas.decor, [
|
||||
{ type: 'Brazier', itemId: 0x0e31, uses: 2 },
|
||||
{ type: 'LargeCrate', itemId: 0x0e3c, uses: 1 },
|
||||
{ type: 'Static', itemId: 0x108f, uses: 1 },
|
||||
])
|
||||
assert.equal(atlas.meta.counts.decor, 3)
|
||||
|
||||
// Every decoration file is fingerprinted like every other source, so an
|
||||
// operator editing one is a tree change the boot path notices.
|
||||
const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/'))
|
||||
assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg'])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('a tree with no decoration at all still builds', () => {
|
||||
// Optional, like the champion file. A shard that has stripped its decoration
|
||||
// has a perfectly good atlas; the decoration verb simply has nothing to offer.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-'))
|
||||
try {
|
||||
writeTree(root)
|
||||
fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true })
|
||||
const atlas = buildAtlas(root)
|
||||
assert.deepEqual(atlas.decor, [])
|
||||
assert.equal(atlas.meta.counts.decor, 0)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,24 +27,60 @@ let calls
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { broadcast: [], crier: [], crierDel: [], news: [], newsDel: [] }
|
||||
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
||||
calls = {
|
||||
broadcast: [], crier: [], crierDel: [], news: [], newsDel: [],
|
||||
spawn: [], despawn: [], owned: [],
|
||||
}
|
||||
for (const name of [
|
||||
'adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews',
|
||||
'spawnWorld', 'ownedWorld', 'despawnWorld',
|
||||
]) {
|
||||
saved[name] = uoLinkClient[name]
|
||||
}
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
saved.listRegions = shardAtlas.listRegions
|
||||
saved.listLandmarks = shardAtlas.listLandmarks
|
||||
saved.searchCreatures = shardAtlas.searchCreatures
|
||||
saved.listDecorTypes = shardAtlas.listDecorTypes
|
||||
saved.getDecorType = shardAtlas.getDecorType
|
||||
|
||||
uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } }
|
||||
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
||||
// Phase 12a. Two serials back by default, so a spawn produces a resource list
|
||||
// longer than one and the per-serial ledger shape is what the suite exercises.
|
||||
uoLinkClient.spawnWorld = async (b) => {
|
||||
calls.spawn.push(b)
|
||||
const n = b.count || 1
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { serials: Array.from({ length: n }, (_, i) => `0x4000000${i}`) },
|
||||
}
|
||||
}
|
||||
uoLinkClient.ownedWorld = async (b) => {
|
||||
calls.owned.push(b)
|
||||
return { ok: true, status: 200, data: { owned: [{ serial: '0x40000000', what: 'creature' }] } }
|
||||
}
|
||||
uoLinkClient.despawnWorld = async (b) => {
|
||||
calls.despawn.push(b)
|
||||
return { ok: true, status: 200, data: { removed: b.serials || [], gone: [], refused: [] } }
|
||||
}
|
||||
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
||||
// Phase 11b. `uo.participation.open` resolves its `place` param against the
|
||||
// atlas, so the dry-run sweep below reaches this rather than the database.
|
||||
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||
// Two landmarks, because Phase 12a's gate verb resolves a SECOND place: its
|
||||
// destination. One would make the dry-run sweep below pass for the wrong
|
||||
// reason, by never exercising the leg that can name a different point.
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 },
|
||||
{ facet: 'Felucca', name: 'Yew', x: 542, y: 982, z: 0 },
|
||||
]
|
||||
shardAtlas.listDecorTypes = async () => [{ type: 'Brazier', itemId: 0x0E31, uses: 42 }]
|
||||
shardAtlas.getDecorType = async (type) =>
|
||||
type === 'Brazier' ? { type: 'Brazier', itemId: 0x0E31, uses: 42 } : null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,6 +91,11 @@ afterEach(() => {
|
||||
shardAtlas.listRegions = saved.listRegions
|
||||
shardAtlas.listLandmarks = saved.listLandmarks
|
||||
shardAtlas.searchCreatures = saved.searchCreatures
|
||||
shardAtlas.listDecorTypes = saved.listDecorTypes
|
||||
shardAtlas.getDecorType = saved.getDecorType
|
||||
for (const name of ['spawnWorld', 'ownedWorld', 'despawnWorld']) {
|
||||
uoLinkClient[name] = saved[name]
|
||||
}
|
||||
})
|
||||
|
||||
// ── The rule everything else depends on ────────────────────────────────────
|
||||
@@ -116,9 +157,15 @@ test('the declarations satisfy the shape core validates them with', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('a broadcast spends the one budget dimension the module declares', () => {
|
||||
test('every dimension a cost names is one this module declares', () => {
|
||||
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
||||
assert.deepEqual([...declared], ['uo.broadcasts'])
|
||||
// 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.
|
||||
assert.deepEqual(
|
||||
[...declared],
|
||||
['uo.broadcasts', 'uo.creatures', 'uo.bosses', 'uo.npcs', 'uo.decor', 'uo.gate.minutes'],
|
||||
)
|
||||
for (const b of actions.BUDGETS) {
|
||||
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
||||
assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number')
|
||||
@@ -134,6 +181,24 @@ test('a broadcast spends the one budget dimension the module declares', () => {
|
||||
// id, so there is no runaway for a cap to bound.
|
||||
assert.equal(byId('uo.towncrier.post').cost, undefined)
|
||||
assert.equal(byId('uo.news.post').cost, undefined)
|
||||
|
||||
// Phase 12a. Asserted across EVERY action rather than one at a time, because
|
||||
// the failure this catches is a typo in one dimension name out of six, which
|
||||
// core answers by refusing the whole registration at load.
|
||||
for (const action of actions.ACTIONS) {
|
||||
if (typeof action.cost !== 'function') continue
|
||||
const params = {}
|
||||
for (const p of action.params) params[p.name] = p.example
|
||||
for (const id of Object.keys(action.cost(params))) {
|
||||
assert.ok(declared.has(id), `${action.id} spends "${id}", which nothing declares`)
|
||||
}
|
||||
}
|
||||
|
||||
// A gate is priced in MINUTES, not in gates. One standing all day and twelve
|
||||
// standing five minutes each are not the same imposition on a world, and a
|
||||
// count would price them identically.
|
||||
assert.deepEqual(byId('uo.gate.open').cost({ durationMinutes: 120 }), { 'uo.gate.minutes': 120 })
|
||||
assert.deepEqual(byId('uo.creature.spawn').cost({ count: 8 }), { 'uo.creatures': 8 })
|
||||
})
|
||||
|
||||
// ── uo.broadcast: retried, because protocol 6 made that safe ───────────────
|
||||
@@ -450,16 +515,26 @@ test('a landmark groups by the atlas grouping where it has one, the facet otherw
|
||||
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
|
||||
})
|
||||
|
||||
test('a creature needs no qualifier — the slug is the same type wherever it spawns', async () => {
|
||||
test('a creature option carries the type the shard can build, not the atlas slug', async () => {
|
||||
// Changed in Phase 12a, and the reason is the point of the source existing.
|
||||
// Wave 1 declared it before anything consumed it and used the slug — unique,
|
||||
// stable, and unusable: the shard constructs 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.
|
||||
shardAtlas.searchCreatures = async ({ limit }) => {
|
||||
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
|
||||
return { creatures: [{ slug: 'orc-brute', name: 'Orc Brute' }] }
|
||||
return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] }
|
||||
}
|
||||
assert.deepEqual(await source('uo.options.creatures').resolve(), [
|
||||
{ value: 'orc-brute', label: 'Orc Brute' },
|
||||
{ value: 'OrcBrute', label: 'OrcBrute' },
|
||||
])
|
||||
})
|
||||
|
||||
test('decoration options come from the shard\'s own decoration files', async () => {
|
||||
const options = await source('uo.options.decor').resolve()
|
||||
assert.deepEqual(options, [{ value: 'Brazier', label: 'Brazier' }])
|
||||
})
|
||||
|
||||
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
|
||||
const { ctx } = require('./_setup')
|
||||
shardAtlas.listRegions = async () =>
|
||||
@@ -475,3 +550,279 @@ test('an atlas larger than the dropdown bound is truncated and said so', async (
|
||||
.some(([message]) => /truncated/.test(message))
|
||||
assert.ok(warned, 'a truncated source must leave a log line naming itself')
|
||||
})
|
||||
|
||||
// ── The world verbs (Phase 12a) ───────────────────────────────
|
||||
|
||||
test('a spawn files one ledger row per serial, not one per call', async () => {
|
||||
// Per serial, because a group half of which a player killed has to reconcile
|
||||
// per creature. One row per call would make teardown all-or-nothing over eight
|
||||
// orcs of which six are gone, which is neither true nor useful.
|
||||
const result = await byId('uo.creature.spawn').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'c'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', creature: 'Orc', count: 3 },
|
||||
verify: false,
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.resources.length, 3)
|
||||
for (const resource of result.resources) {
|
||||
assert.equal(resource.kind, actions.OWNED_KIND)
|
||||
assert.equal(resource.payload.runId, '7')
|
||||
assert.equal(resource.payload.what, 'creature')
|
||||
assert.equal(resource.payload.type, 'Orc')
|
||||
}
|
||||
|
||||
// The place is resolved to a point HERE, so the shard is never handed a
|
||||
// facet/name it would have to know how to read.
|
||||
assert.equal(calls.spawn.length, 1)
|
||||
assert.deepEqual(
|
||||
{ map: calls.spawn[0].map, x: calls.spawn[0].x, y: calls.spawn[0].y },
|
||||
{ map: 'Felucca', x: 1496, y: 1628 },
|
||||
)
|
||||
})
|
||||
|
||||
test('a boss is a creature plus multipliers, and is refused above the ceiling', async () => {
|
||||
const boss = byId('uo.boss.spawn')
|
||||
const params = {
|
||||
place: 'Felucca/Britain',
|
||||
creature: 'OrcCaptain',
|
||||
name: 'Gruk the Unbroken',
|
||||
hitsMultiplier: 3,
|
||||
damageMultiplier: 1.5,
|
||||
}
|
||||
|
||||
assert.equal((await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params, verify: false })).ok, true)
|
||||
assert.equal(calls.spawn[0].what, 'boss')
|
||||
assert.equal(calls.spawn[0].hitsMultiplier, 3)
|
||||
assert.equal(calls.spawn[0].damageMultiplier, 1.5)
|
||||
// Absent, not zero: a multiplier nobody set must not arrive as a number the
|
||||
// shard would then apply.
|
||||
assert.equal(calls.spawn[0].statMultiplier, undefined)
|
||||
|
||||
const tooMuch = await boss.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'b'.repeat(40),
|
||||
params: { ...params, hitsMultiplier: actions.MAX_BOSS_MULTIPLIER + 1 },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(tooMuch.ok, false)
|
||||
assert.equal(tooMuch.retry, false, 'a ceiling will not move on a retry')
|
||||
assert.equal(calls.spawn.length, 1, 'nothing may reach the shard once it is refused here')
|
||||
|
||||
// Named, because an unnamed boss is just a hard orc — and because the name is
|
||||
// what an operator reads in the ledger afterwards.
|
||||
const unnamed = await boss.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'b'.repeat(40),
|
||||
params: { ...params, name: ' ' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(unnamed.ok, false)
|
||||
})
|
||||
|
||||
test('an oracle\'s dialogue is parsed from one textarea, and a bad row is named', async () => {
|
||||
const parsed = actions.oracleLines('fire, flame = It burns beneath the keep.\n gate = At dusk. ')
|
||||
assert.deepEqual(parsed, {
|
||||
ok: true,
|
||||
rows: [
|
||||
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
|
||||
{ keywords: 'gate', text: 'At dusk.' },
|
||||
],
|
||||
})
|
||||
|
||||
// Split on the FIRST `=`, so an answer may contain one.
|
||||
assert.deepEqual(actions.oracleLines('sum = 2 = 2 is four').rows, [
|
||||
{ keywords: 'sum', text: '2 = 2 is four' },
|
||||
])
|
||||
|
||||
assert.equal(actions.oracleLines('just some prose').ok, false)
|
||||
assert.equal(actions.oracleLines('fire =').ok, false, 'a keyword with nothing to say is a mistake')
|
||||
assert.equal(actions.oracleLines('= something').ok, false, 'something to say with no keyword is too')
|
||||
|
||||
const tooMany = actions.oracleLines(
|
||||
Array.from({ length: actions.MAX_ORACLE_LINES + 1 }, (_, i) => `w${i} = t${i}`).join('\n'),
|
||||
)
|
||||
assert.equal(tooMany.ok, false)
|
||||
})
|
||||
|
||||
test('an oracle with nothing to say is refused before it is stood up', async () => {
|
||||
// `required: true` on the greeting catches an ABSENT field, at the edge, and
|
||||
// this catches the one holding nothing but spaces — which reaches `perform`
|
||||
// looking exactly like a filled-in form.
|
||||
const result = await byId('uo.npc.place').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'n'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', name: 'Marisa', greeting: ' ' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, false)
|
||||
assert.match(result.error, /silence/)
|
||||
assert.deepEqual(calls.spawn, [])
|
||||
})
|
||||
|
||||
test('a keyword line reaches the shard as keywords and text, and nothing executable', async () => {
|
||||
// The whole argument for not building this on `XmlSpawner2.XmlDialog`, which
|
||||
// implements exactly this vocabulary and one field more: an `Action` string
|
||||
// that runs commands. What crosses here is what an oracle SAYS.
|
||||
const result = await byId('uo.npc.place').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'n'.repeat(40),
|
||||
params: {
|
||||
place: 'Felucca/Britain',
|
||||
name: 'Marisa',
|
||||
greeting: 'You have questions.',
|
||||
lines: 'fire, flame = It burns beneath the keep.',
|
||||
sex: 'female',
|
||||
},
|
||||
verify: false,
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(calls.spawn[0].lines, [
|
||||
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
|
||||
])
|
||||
assert.equal(calls.spawn[0].sex, 'female')
|
||||
for (const key of Object.keys(calls.spawn[0])) {
|
||||
assert.notEqual(key, 'action', 'nothing executable may cross to the shard')
|
||||
}
|
||||
})
|
||||
|
||||
test('a gate crosses as a DURATION, and names both ends as points', async () => {
|
||||
const result = await byId('uo.gate.open').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'g'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: 120 },
|
||||
verify: false,
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
const sent = calls.spawn[0]
|
||||
// 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.
|
||||
assert.equal(sent.holdMs, 120 * 60_000)
|
||||
assert.equal(sent.untilMs, undefined, 'an absolute deadline must not cross')
|
||||
assert.deepEqual(sent.target, { map: 'Felucca', x: 542, y: 982 })
|
||||
|
||||
const tooLong = await byId('uo.gate.open').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'g'.repeat(40),
|
||||
params: {
|
||||
place: 'Felucca/Britain',
|
||||
destination: 'Felucca/Yew',
|
||||
durationMinutes: actions.MAX_GATE_MINUTES + 1,
|
||||
},
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(tooLong.ok, false)
|
||||
assert.equal(tooLong.retry, false)
|
||||
})
|
||||
|
||||
test('teardown reports a refused serial as failed, and a killed creature as done', async () => {
|
||||
const resources = [
|
||||
{ kind: 'world', ref: '0x40000000', payload: {} },
|
||||
{ kind: 'world', ref: '0x40000001', payload: {} },
|
||||
]
|
||||
|
||||
// `gone` is not a failure. 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 — so a run does not end `incomplete` because its event worked.
|
||||
uoLinkClient.despawnWorld = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { removed: ['0x40000000'], gone: ['0x40000001'], refused: [] },
|
||||
})
|
||||
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true })
|
||||
|
||||
// `refused` IS. The shard denies this run ever owned it, so nothing will ever
|
||||
// delete it through this path: the row must land unresolved with a reason
|
||||
// rather than be quietly marked reverted.
|
||||
uoLinkClient.despawnWorld = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { removed: ['0x40000000'], gone: [], refused: ['0x40000001'] },
|
||||
})
|
||||
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), {
|
||||
ok: true,
|
||||
failed: ['0x40000001'],
|
||||
})
|
||||
|
||||
// An unreachable shard has not said anything about anything.
|
||||
uoLinkClient.despawnWorld = async () => ({ ok: false, status: 503, data: null })
|
||||
assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false)
|
||||
})
|
||||
|
||||
test('reconcile ASKS the shard, because these resources survive a restart', async () => {
|
||||
// The one property that separates this from every other resource in the file.
|
||||
// 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
|
||||
// boot stamp would report it lost by.
|
||||
const resources = [
|
||||
{ kind: 'world', ref: '0x40000000', payload: {} },
|
||||
{ kind: 'world', ref: '0x40000001', payload: {} },
|
||||
]
|
||||
|
||||
assert.deepEqual(await actions.reconcileOwned({ runId: 7, resources }), {
|
||||
ok: true,
|
||||
inForce: ['0x40000000'],
|
||||
})
|
||||
assert.deepEqual(calls.owned, [{ runId: '7' }])
|
||||
|
||||
// "I could not ask" must never be read as "it is gone": an unanswered group
|
||||
// leaves every row alone rather than orphaning the lot.
|
||||
uoLinkClient.ownedWorld = async () => ({ ok: false, status: 504, data: null })
|
||||
assert.equal((await actions.reconcileOwned({ runId: 7, resources })).ok, false)
|
||||
})
|
||||
|
||||
test('every world verb declares the same undo contract', async () => {
|
||||
// Five declarations sharing one spread object, asserted rather than assumed:
|
||||
// a verb that quietly lost its `reconcile` would leave its rows unanswered for
|
||||
// the life of the run, and nothing would report it — which is exactly the hole
|
||||
// Phase 11b found in `core.lease`.
|
||||
for (const id of ['uo.creature.spawn', 'uo.boss.spawn', 'uo.npc.place', 'uo.gate.open', 'uo.decor.place']) {
|
||||
const action = byId(id)
|
||||
assert.equal(action.risk, 'change', `${id} must be a world change`)
|
||||
assert.equal(action.reversible, 'ledger', `${id} owns what it made`)
|
||||
assert.equal(typeof action.revert, 'function', `${id} has no undo`)
|
||||
assert.equal(typeof action.reconcile, 'function', `${id} can never be asked what it still holds`)
|
||||
assert.ok(action.budgetMs > 12000, `${id} must outlast the client's own timeout`)
|
||||
assert.equal(typeof action.cost, 'function', `${id} is capped by nothing`)
|
||||
}
|
||||
})
|
||||
|
||||
test('decoration carries the graphic, and a type this shard never decorates with is refused', async () => {
|
||||
const decor = byId('uo.decor.place')
|
||||
|
||||
const ok = await decor.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'd'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', item: 'Brazier', count: 2 },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(ok.ok, true)
|
||||
assert.equal(ok.resources.length, 2)
|
||||
|
||||
// **The item id crosses, and it has to.** Measured on ServUO 57.4, `Static`
|
||||
// accounts for 5031 decoration placements under 1992 DIFFERENT graphics,
|
||||
// because for that class the graphic is the identity: a bare `new Static()`
|
||||
// is never the paving stone the author picked. 131 of 313 types carry more
|
||||
// than one id.
|
||||
assert.equal(calls.spawn[0].type, 'Brazier')
|
||||
assert.equal(calls.spawn[0].itemId, 0x0e31)
|
||||
|
||||
// Resolving through the atlas is also the boundary: the verb places what this
|
||||
// shard's own decoration files name, which is tighter than "any item that is
|
||||
// not a container" and is the rule the decision actually took.
|
||||
const unknown = await decor.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'd'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', item: 'BlackrockCrate', count: 1 },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(unknown.ok, false)
|
||||
assert.equal(unknown.retry, false)
|
||||
assert.match(unknown.error, /never mention/)
|
||||
assert.equal(calls.spawn.length, 1)
|
||||
})
|
||||
|
||||
@@ -548,6 +548,48 @@ function walkLocations(node, facet, path, out) {
|
||||
* A spawn with no `type` is randomised on every activation, which the site must
|
||||
* render as "random" rather than as an empty type.
|
||||
*/
|
||||
/**
|
||||
* Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`.
|
||||
*
|
||||
* The format is a header line naming a type and an item id, optionally followed
|
||||
* by a parenthesised property list, and then one `x y z` line per placement:
|
||||
*
|
||||
* ```
|
||||
* # switch
|
||||
* Static 0x108F
|
||||
* 5552 1864 11
|
||||
* ```
|
||||
*
|
||||
* Only the header matters here. The properties are decoration-authoring details
|
||||
* (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its
|
||||
* own scenery, neither of which an event author is choosing — they pick a type
|
||||
* and a place of their own.
|
||||
*
|
||||
* Returns one entry per header line, not per distinct type: the same type
|
||||
* appears under many item ids (a `BarredMetalDoor` for each facing), and how
|
||||
* often a shard reaches for something is worth keeping. `spawnAtlasSource`
|
||||
* aggregates.
|
||||
*/
|
||||
function parseDecoration(source) {
|
||||
const out = []
|
||||
if (!source) return out
|
||||
|
||||
for (const raw of String(source).split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
|
||||
// A coordinate line starts with a digit or a minus (z is often negative),
|
||||
// so the type test is not merely "not a comment".
|
||||
if (line === '' || line.startsWith('#')) continue
|
||||
|
||||
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line)
|
||||
if (!match) continue
|
||||
|
||||
out.push({ type: match[1], itemId: parseInt(match[2], 16) })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function parseChampions(source) {
|
||||
const root = parseXml(source)
|
||||
const champions = []
|
||||
@@ -675,6 +717,7 @@ module.exports = {
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
parseDecoration,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
parseDecoration,
|
||||
buildPlacementIndex,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
@@ -35,6 +36,7 @@ const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||
const SPAWNS_DIR = 'Spawns'
|
||||
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||
const DECORATION_DIR = path.join('Data', 'Decoration')
|
||||
|
||||
class AtlasSourceError extends Error {
|
||||
constructor(message, code) {
|
||||
@@ -62,6 +64,33 @@ function listXml(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `.cfg` under `dir`, recursively, tree-relative and forward-slashed.
|
||||
*
|
||||
* Recursive because `Data/Decoration` nests two deep in places
|
||||
* (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`) and a flat read
|
||||
* would silently index a third of what the shard actually has — the failure
|
||||
* mode being a dropdown that is quietly missing whole expansions rather than an
|
||||
* error anyone would notice.
|
||||
*/
|
||||
function listCfgTree(dir, prefix = '') {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||
throw err
|
||||
}
|
||||
|
||||
const out = []
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) out.push(...listCfgTree(path.join(dir, entry.name), rel))
|
||||
else if (entry.name.toLowerCase().endsWith('.cfg')) out.push(rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
@@ -111,6 +140,12 @@ function readSources(root) {
|
||||
|
||||
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||
|
||||
// Optional, like the champion file: a shard that has stripped its decoration still
|
||||
// has a usable atlas, it just cannot offer the decoration verb anything to place.
|
||||
for (const rel of listCfgTree(path.join(root, DECORATION_DIR))) {
|
||||
push(`Data/Decoration/${rel}`, path.join(root, DECORATION_DIR, rel))
|
||||
}
|
||||
|
||||
return { files }
|
||||
}
|
||||
|
||||
@@ -141,7 +176,7 @@ function hashSources(root) {
|
||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||
* seconds in the source, decided by `DelayInSec`).
|
||||
*/
|
||||
const PARSER_VERSION = 2
|
||||
const PARSER_VERSION = 3
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
@@ -294,6 +329,25 @@ function buildAtlas(root, options = {}) {
|
||||
}
|
||||
})
|
||||
|
||||
// Decoration: what this shard already calls scenery, which is what makes the
|
||||
// authoring dropdown the operator's own vocabulary rather than our taste.
|
||||
const decorUses = new Map()
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Data/Decoration/')) continue
|
||||
for (const entry of parseDecoration(file.text)) {
|
||||
const seen = decorUses.get(entry.type)
|
||||
if (seen) {
|
||||
seen.uses += 1
|
||||
continue
|
||||
}
|
||||
// The FIRST item id wins, and it is only a preview: a type appears under
|
||||
// as many ids as it has facings or variants, and picking one arbitrarily
|
||||
// is honest in a way that picking "the most used" would not be.
|
||||
decorUses.set(entry.type, { type: entry.type, itemId: entry.itemId, uses: 1 })
|
||||
}
|
||||
}
|
||||
const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type))
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
@@ -311,6 +365,7 @@ function buildAtlas(root, options = {}) {
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
decor: decor.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
@@ -321,6 +376,7 @@ function buildAtlas(root, options = {}) {
|
||||
landmarks,
|
||||
champions,
|
||||
points,
|
||||
decor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,36 @@ const closeParticipation = ({ runId, idempotencyKey }) =>
|
||||
body: { idempotencyKey },
|
||||
})
|
||||
|
||||
// ── The world verbs (protocol 7) ───────────────────────────────
|
||||
//
|
||||
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
|
||||
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
|
||||
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
|
||||
// `target`/`holdMs` for a gate.
|
||||
//
|
||||
// The shard registers every serial it places against the run and persists that
|
||||
// registry, which is what makes `despawnWorld` below safe to point at a list of
|
||||
// serials: it can only delete what the run actually owns.
|
||||
const spawnWorld = (body) => call('/world', { method: 'POST', body })
|
||||
|
||||
// What the run still owns. A GET, unlike the participation snapshot: it carries no
|
||||
// idempotency key and the shard answers it in one pass. An unknown run answers with an
|
||||
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
|
||||
// fact once the registry is the only record, and they stay the same fact across a
|
||||
// restart, because the registry is written by the same world save as the objects it
|
||||
// describes.
|
||||
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
|
||||
|
||||
// Give back what the run owns. No `serials` means everything, which is the call
|
||||
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
|
||||
// already absent (a player killed it — an ordinary success), and `refused` was never
|
||||
// this run's to delete.
|
||||
const despawnWorld = ({ runId, serials, idempotencyKey }) =>
|
||||
call(`/world/${encodeURIComponent(runId)}/despawn`, {
|
||||
method: 'POST',
|
||||
body: { serials, idempotencyKey },
|
||||
})
|
||||
|
||||
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||
const respondPage = (pageId, { message, close }) =>
|
||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||
@@ -328,6 +358,9 @@ module.exports = {
|
||||
openParticipation,
|
||||
snapshotParticipation,
|
||||
closeParticipation,
|
||||
spawnWorld,
|
||||
ownedWorld,
|
||||
despawnWorld,
|
||||
adminKick,
|
||||
adminBan,
|
||||
adminUnban,
|
||||
|
||||
Reference in New Issue
Block a user