8 Commits

Author SHA1 Message Date
bbaf08f67c Merge pull request 'feat(events): the UO half of the Event System, and the core pin comes home (Phase 16b cutover, 3 of 6)' (#34) from chore/events-cutover-repin into main
Some checks failed
Release / release (push) Failing after -45s
SonarQube / analysis (push) Successful in 2m30s
Reviewed-on: #34
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 01:23:30 +00:00
ea63ad019c chore(ci): the core pin comes home to main (Phase 16b cutover)
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / frozen-manifest (pull_request) Successful in 49s
`ci/core-ref.json` pointed at a website `edge` sha for the length of the Event
System window (org lead, 2026-09-04), because `api.registerEventActions` exists
only from MODULE_API 1.10.0: under the old `main` pin the frozen-manifest job's
`register()` threw and this module did not load at all, so the job would have
been red by construction for eight phases while a real regression hid behind it.

The cutover put 1.10.0 on `main` (website#199, 655fbf3f), so the pin returns to
a `main` sha -- and this is the same move that turns the Integration kit green,
since `checkCoreApi` asserts equality against whatever core this pin names.

`routes.manifest.json` needed NO regeneration. The frozen-manifest job's own
steps were run against this exact ref -- core's manifest alone, the module
installed, core's manifest again, then `frozenManifest.js --check` -- and it
answered `routes.manifest.json is current, 73 routes, all documented`. So the
file's own "commit both together" instruction had nothing to pair with this
time. website's `main` and `edge` are the identical tree (930422ff), which is
why the measurement taken on the branch holds for the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:48:29 -05:00
c73d62e93a Merge pull request 'fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)' (#33) from fix/events-p16a-walk into edge
Reviewed-on: #33
2026-09-09 13:47:56 +00:00
8def6e19f4 fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 58s
PR Checks / server-tests (pull_request) Successful in 8m27s
Two defects the acceptance walk found in shipped code, both invisible to the
suites that were green on either side of them.

**The spawn atlas cannot import on a stock ServUO tree.** `spawnAtlasSource.js`
dedupes decoration types with a case-SENSITIVE `Map`, but `shard_decor_types.type`
is a PRIMARY KEY under MariaDB's default `..._ai_ci` collation, which folds case.
Stock 57.4's own `Data/Decoration/` names four types under two spellings each
(CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
SpinningWheelEastAddon/SpinningwheelEastAddon), and in every pair exactly one is a
real class. The second row raised `1062 Duplicate entry` and took the WHOLE import
transaction down. The blast radius is not decoration: with no atlas, EVERY option
source answers empty and no Phase 12 world verb can be authored at all.

The shard end already knew — `BridgeWorld.cs` resolves a decor type with
`FindTypeByName(name, ignoreCase: true)` and its comment says the atlas and the
decoration files disagree about casing. Folding here is the two ends agreeing.

**Teardown of every world verb was a no-op that reported success.** `revertOwned`
forwarded core's `idempotencyKey` as the despawn's OWN key — and core's key is the
step's, the one `placeOwned` spawned under. `BridgeIdempotency` keys on the key
alone, so the despawn was taken for a repeat and answered with the SPAWN's stored
reply; `OnDespawn` never ran. Core read `ok` with no `refused` and marked every
row `reverted` while the shard still held every object.

Measured on the rig: ledger `world | reverted | 21`, shard `world.owned` 21 alive
with `pruned: 0`, and the identical despawn re-sent with a fresh key removed all
21. It affected all five world verbs, so an invasion's creatures, boss, oracle,
gate and decoration stayed in the world for ever while the console reported a
clean teardown.

`MODULE_API.md` says what that key is for and it is not this: it identifies a
dispatch core never learned the outcome of, so the module can ask about it. No key
is needed on a despawn — a repeat answers `gone`, which both ends already treat as
success — and dropping it also makes the documented empty-`resources` case work,
since no serials means "everything this run owns". The parameter is removed from
`despawnWorld`'s signature rather than left optional.

Both fixes are verified end to end against a real ServUO + sidecar + website rig:
the import now yields 309 decor types (was failing at 313 with 4 collisions),
6,455 spawn points, 800 creatures, 558 landmarks; and a full four-phase run's
teardown left the shard owning 0 objects.

Each new test was confirmed to FAIL without its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:27:50 -05:00
c289586a3d Merge pull request 'feat(events): what an author borrows, and two one-shots (Phase 12b)' (#32) from feature/events-p12b-borrowed-and-oneshots into edge
Reviewed-on: #32
2026-09-07 16:23:32 +00:00
10fde87724 feat(events): what an author borrows, and two one-shots (Phase 12b)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 53s
Five targeted leases over two planes, the item grant, the world save, and the
atlas work the spawner dropdown needed.

FIVE LEASES, ONE FACTORY

`uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and
`uo.seasonal.status`. The four callables differ only in which key they name, so
they are built rather than repeated: five copies would be five chances for one of
them to forget the drift check, which is the one thing §F says a lease must not
be allowed to skip.

It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such
property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries
SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a
real tree, and a unit that cannot express five seconds cannot express this
shard's own data.

The seasonal lease is a THREE-value enum over EIGHT events. §G called
`GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus`
has three values and it is `EventType` that has nine entries. Eight rather than
nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own
`DropEra` rather than `Status`, so leasing it would apply cleanly, read back,
restore cleanly and do nothing at all.

Two behaviours worth the review. `inForce()` reads the frame's `holds` rather
than a row's `held` flag, because a catalog walk can enumerate the keys but never
the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL
restore: there is nothing to give back, and reporting it failed would leave a
ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the
lease plane's vocabulary.

THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST

Core has the participants in `event_run_participants`, but a module cannot read
core's tables -- so the alternative was a new core surface handing them over. Not
needed: the shard has held the run's ledger since it opened, keyed by the same
serials core stores as `member_key`.

And the grant is RETRYABLE. §G called it un-retryable because a lost
acknowledgement and a grant that never applied were the same event, which is
exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9.
Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than
grants: 500 gold to forty people and a candle to forty people are not the same
imposition.

THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES

The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a
line citing a committed artifact -- there is no committed artifact, as
`spawnAtlasSource.js` says in its own header. It is the ONLY name for one
particular spawner that exists off the shard, so a property lease could not have
had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read.

`uo.options.spawners` is the first searchable source and the first that had to
be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop
two thirds of the world and say nothing about which two thirds.

ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING

The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as
`X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from
Phase 11a onward every sidecar call on a real deployment would have been refused
-- the whole event plane dead, loudly, for a reason nobody would look here for.
11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two
places this repo declares it. It survived both because both live walks set the
column by hand while standing the rig up, which is exactly what makes a migration
nobody runs invisible. All three sites go to 7.

The test that guards them is worth understanding before trusting it:
`schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER --
a real check they once failed -- but all three being equally stale passes it, and
nothing in this repo can anchor it to the wire. Recorded in the model's own
header so the next reader knows.

CHECKS

`npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals`
clean; the client builds and its 42 tests pass. `check:swagger` reports the
fragment stale -- it is ALREADY stale on `edge` (verified by stashing this
branch's changes and re-running) and this phase adds no route, so it is left
alone rather than regenerated inside an unrelated change.

Two bugs the new tests caught in this branch's own code before it left: `counted()`
returns `.count` and the grant read `.value`, so every grant went out with
`amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s
`ok` was ignored, so a bad hue passed silently instead of refusing.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:08:08 -05:00
0b142adb81 Merge pull request 'feat(events): the five world verbs an author sees (Phase 12a)' (#31) from feature/events-p12a-world-verbs into edge
Reviewed-on: #31
2026-09-07 06:58:04 +00:00
89be9d6a4e 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
2026-09-07 01:52:15 -05:00
14 changed files with 2728 additions and 46 deletions

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It points at `edge` for the length of the Event System window** (org lead, 2026-09-04), and that is the one line here a reader should not tidy back. This module registers event actions from EVENTS_PLAN.md Phase 9, and `api.registerEventActions` exists only from MODULE_API 1.10.0 -- under the previous `main` pin `register()` throws and the module does not load at all, so the job would be red by construction for eight phases and would prove nothing while a real regression hid behind it. Phase 16's cutover re-pins it to `main`, which is the same commit that turns the Integration kit green again.",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "d4516739b43de5cb83b8f0333f8f966280a5632f",
"refName": "edge @ MODULE_API 1.10.0, the event module contract (website#189, #190)"
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)"
}

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 5,
protocol INT NOT NULL DEFAULT 7,
enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL,
@@ -485,6 +485,13 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
-- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner
-- that exists OFF the shard. A property lease is targeted by it, because a
-- serial is assigned when the world is built and nothing here could know one --
-- so without this column the lease's target field could have no dropdown at
-- all. NULLable: a shard's own spawners, added in-world rather than from the
-- spawn files, carry none, and they are addressed by serial instead.
unique_id VARCHAR(64) NULL,
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
@@ -500,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
INDEX idx_shard_spawn_points_label (label),
-- The spawner target's dropdown searches by name, and 6,707 rows is more than
-- a dropdown holds, so the search is the read rather than a filter over one.
INDEX idx_shard_spawn_points_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
@@ -544,6 +554,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").
@@ -786,3 +816,24 @@ UPDATE uo_link_config SET protocol = 5
WHERE id = 1 AND protocol < 5
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged
-- code rather than ordinary Phase 12b work.
--
-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved
-- this. `uoLinkClient` sends `X-UOLink-Version: <this column>` on every call and
-- the sidecar answers an exact mismatch with a 409, so a deployment that installed
-- this module at any point since Phase 10 would have had EVERY sidecar call
-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but
-- for a reason nobody would look here for.
--
-- It survived two phases because both live walks set the column by hand while
-- standing the rig up, which is exactly the shape of a migration nobody runs.
-- One block carries an install the whole way rather than one per missed version:
-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote
-- `< n` rather than `= n-1`.
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7;
UPDATE uo_link_config SET protocol = 7
WHERE id = 1 AND protocol < 7
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1');

View File

@@ -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
@@ -110,13 +121,14 @@ async function replaceAtlas(atlas, art = {}) {
counts.points = await insertBatched(
conn,
'INSERT INTO shard_spawn_points ' +
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
'(id, facet, name, unique_id, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
atlas.points.map((p, i) => [
i + 1,
p.facet,
p.name,
p.uniqueId || null,
p.x,
p.y,
p.width ?? 0,
@@ -357,6 +369,73 @@ 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,
)
}
/**
* Spawners an author can name, searched by name and bounded (Phase 12b).
*
* **A search rather than a list, and the numbers are why.** This tree has 6,707
* spawn points against a 2,000-entry dropdown bound, so a flat read would drop
* two thirds of the world and say nothing about which two thirds — the failure
* Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew
* a `q` for this.
*
* Only rows with a `unique_id` are offered: that is the only name for a spawner
* that exists off the shard, and a row without one cannot be targeted from a
* form however it is labelled. A shard's own in-world spawners have none and are
* addressed by serial, which an author types rather than picks.
*
* Ordered by `max_count DESC` so the spawners worth an event's attention come
* first, with a stable alphabetical tiebreak for a form somebody scrolls.
*/
function listSpawners({ q = '', limit = 200 } = {}) {
const where = ['unique_id IS NOT NULL', "unique_id <> ''"]
const params = []
if (q) {
where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)')
params.push(`%${q}%`, `%${q}%`, `%${q}%`)
}
params.push(Number(limit) || 200)
return query(
`SELECT unique_id, name, facet, region, landmark, max_count
FROM shard_spawn_points
WHERE ${where.join(' AND ')}
ORDER BY max_count DESC, name ASC
LIMIT ?`,
params,
)
}
/** One decoration type, or nothing when this shard's files never name it. */
async function getDecorType(type) {
const rows = await query(
'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 +467,8 @@ module.exports = {
listCreatureCompanions,
listRegions,
listLandmarks,
listDecorTypes,
listSpawners,
getDecorType,
listChampions,
}

View File

@@ -403,6 +403,59 @@ 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,
}))
}
/**
* Spawners an author can name, searched (Phase 12b).
*
* The value is the `UniqueId` because that is what the shard resolves a target
* by; the label is the spawner's own name, which is what an author recognises
* ("fel bulbous putrification" is a place they know). A row with no name still
* answers, labelled by its id, rather than being dropped: a nameless spawner is
* still a spawner somebody may need to turn down.
*/
async function listSpawners(opts = {}) {
const rows = await db.listSpawners(opts)
return rows.map((r) => ({
uniqueId: r.unique_id,
name: r.name || null,
facet: r.facet,
region: r.region || null,
landmark: r.landmark || null,
maxCount: Number(r.max_count) || 0,
}))
}
/**
* One decoration type, or null.
*
* 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 +538,9 @@ module.exports = {
getCreature,
listRegions,
listLandmarks,
listDecorTypes,
listSpawners,
getDecorType,
listChampions,
listFacets,
publicMeta,

View File

@@ -11,16 +11,28 @@ const { secretBox } = require('../../core')
// Only used before an admin has saved anything — the stored row wins once it exists,
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
//
// This says 5 because this build handles protocol 5's frames: house.decay's `schedule`,
// vendor.listing's `ownerAcct` + `fees`, and the new `account.login.result` kind.
// This says 7 because this build speaks protocol 7: the idempotency key and the
// participation ledger (6), and the world verbs plus the targeted lease planes (7).
//
// It said 4 before that, and 3 for a while after protocol 4 shipped — which is the bug
// this constant is now the fix for. A FRESH install pinned 3, the sidecar answered
// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this
// constant was introduced to fix. A FRESH install pinned 3, the sidecar answered
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
// from its shard until an admin edited the number by hand in Admin → Shard. Bumping it
// in the SAME change as the emitters is the discipline that prevents a repeat; see the
// matching cutover in db/schema.sql.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 5
// from its shard until an admin edited the number by hand in Admin → Shard.
//
// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in
// `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call
// on a real deployment would have been refused. Both live walks set the column by hand
// while standing the rig up, which is exactly what makes a migration nobody runs
// invisible. Phase 12b carries all three to 7.
//
// **Nothing in this repo can check this against the wire**, and that is worth knowing
// before trusting the test that guards it: `schemaFragment.test.js` asserts the three
// declarations agree WITH EACH OTHER, which is a real check — they drifted apart once —
// but all three being equally stale passes it. The wire's version lives in `link`
// (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that
// actually pairs them is the installer's bundle check, at deploy time. So bumping this in
// the same change as the emitters is still the discipline, and no test here replaces it.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 7
function toSafe(row) {
if (!row) {

View File

@@ -62,21 +62,71 @@ 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.item.grant',
'uo.news.post',
'uo.npc.place',
'uo.participation.collect',
'uo.participation.open',
'uo.towncrier.post',
'uo.world.save',
],
)
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',
'uo.rewards',
])
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
// on any of the rest applies cleanly and does nothing.
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), ['uo.playercaps.skillcap'])
// Phase 12b adds five TARGETED leases beside it -- a key that names a capability
// over many things, with the target supplied per step. Four spawner properties
// (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property
// on ServUO 57.4) and the seasonal status, which is a three-value enum over eight
// events rather than the nine-value one section G described.
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [
'uo.playercaps.skillcap',
'uo.spawner.maxcount',
'uo.spawner.mindelay',
'uo.spawner.maxdelay',
'uo.spawner.running',
'uo.seasonal.status',
])
// Only the targeted ones declare a target, and every one of them names a source:
// a target field with no list behind it is the free-text box the option-source
// contract exists to replace.
for (const lease of api.record.eventLeases) {
if (lease.id === 'uo.playercaps.skillcap') {
assert.strictEqual(lease.target, undefined, 'a config lease has no target')
continue
}
assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`)
assert.ok(lease.target.source, `${lease.id} has no target source`)
}
assert.deepStrictEqual(
api.record.eventOptionSources.map((s) => s.id).sort(),
['uo.options.creatures', 'uo.options.landmarks', 'uo.options.regions'],
[
'uo.options.creatures',
'uo.options.decor',
'uo.options.items',
'uo.options.landmarks',
'uo.options.regions',
'uo.options.seasonal',
'uo.options.spawners',
],
)
assert.ok(api.record.streams.length > 0)
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')

View File

@@ -14,6 +14,7 @@ const {
buildFacetIndex,
resolveFacetName,
slugify,
parseDecoration,
decodeEntities,
} = require('../utils/spawnAtlasParse')
@@ -144,9 +145,14 @@ test('parsePoints: reads the kept fields and drops the rest', () => {
assert.equal(covetous.minDelay, 300)
assert.equal(covetous.maxDelay, 600)
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
// Dropped fields must not survive into the artifact — this is what keeps it
// under 1 MB.
assert.equal(covetous.uniqueId, undefined)
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
// live spawner — so it is the only name for one particular spawner that exists
// off the shard, and a property lease targets by it. A serial cannot do that
// job: serials are assigned when the world is built and nothing here knows one.
assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370')
// The rest of the dropped fields still are. Triggering, refractory windows,
// proximity and sounds are what the site has no use for.
assert.equal(covetous.proximityTriggerSound, undefined)
})
@@ -599,3 +605,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'), [])
})

View File

@@ -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,81 @@ 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('two spellings of one decoration type fold into one row', () => {
// The Phase 16 acceptance walk's blocking finding. Stock ServUO 57.4's own
// `Data/Decoration/` names four types under two casings each —
// CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
// SpinningWheelEastAddon/SpinningwheelEastAddon — and in every pair exactly one
// is a real class; the other is a mis-cased line the shard's own loader resolves
// anyway.
//
// A case-SENSITIVE Map keeps both. `shard_decor_types.type` is a PRIMARY KEY
// under MariaDB's default `..._ai_ci` collation, which folds case, so the second
// row raised `1062 Duplicate entry` and took the WHOLE atlas import transaction
// down with it. The blast radius is not decoration: with no atlas, EVERY option
// source answers empty and no world verb can be authored at all.
//
// Asserted on the count as well as the row, because the failure mode was two
// rows that a database — not this function — would later refuse.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decorcase-'))
try {
writeTree(root)
fs.writeFileSync(
path.join(root, 'Data', 'Decoration', 'miscased.cfg'),
'checkerboard 0x0FA6\n600 600 0\nCheckerBoard 0x0FA6\n700 700 0\n',
)
const atlas = buildAtlas(root)
const boards = atlas.decor.filter((d) => d.type.toLowerCase() === 'checkerboard')
assert.equal(boards.length, 1, 'two casings of one type must not be two rows')
// First spelling seen wins, exactly as the first item id does. Which one
// survives is cosmetic — the shard resolves either.
assert.equal(boards[0].type, 'checkerboard')
assert.equal(boards[0].uses, 2, 'both lines still count as uses of the one type')
} 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 })
}
})

View File

@@ -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,27 @@ 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's six and Phase 12b's seventh are all the MODULE's (org lead,
// 2026-09-07): core meters what a module declares and holds no UO knowledge, so
// a `uo.` dimension core knew about would be a leak of this game into the engine.
//
// `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty
// people and one giving a candle to forty people are not the same imposition, and
// a count of grants would price them identically.
assert.deepEqual(
[...declared],
[
'uo.broadcasts',
'uo.creatures',
'uo.bosses',
'uo.npcs',
'uo.decor',
'uo.gate.minutes',
'uo.rewards',
],
)
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 +193,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 +527,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 +562,324 @@ 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('the despawn carries NO idempotency key, whatever core hands revert()', async () => {
// The Phase 16 acceptance walk's critical finding, as the test that would have
// caught it. `revertOwned` used to forward core's `idempotencyKey` onto the
// despawn — and core's key is the STEP's, the one `placeOwned` spawned under.
// The shard's at-most-once store is keyed on the key ALONE
// (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no
// reference to which command carried it), so the despawn was taken for a repeat
// and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read
// `ok` with no `refused` and marked every row `reverted` while the shard still
// held every object — teardown of all five world verbs was a no-op that
// reported success.
//
// Every other stub in this file ignores the body, which is why the suite was
// green throughout. This one asserts on the body, and it asserts ABSENCE — the
// property that matters — rather than pinning the rest of the shape.
let sent = null
uoLinkClient.despawnWorld = async (body) => {
sent = body
return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } }
}
await actions.revertOwned({
runId: 7,
resources: [{ kind: 'world', ref: '0x40000000', payload: {} }],
// Core passes this on every call (MODULE_API.md), and it must not reach the wire.
idempotencyKey: 'the-step-key-the-spawn-went-out-under',
})
assert.ok(sent, 'despawnWorld was not called')
assert.equal(
Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'),
false,
'the despawn must not carry an idempotency key — the shard would replay the spawn',
)
// MODULE_API.md: revert is sometimes called with the key and an EMPTY list,
// meaning "a command went out under this key and core never learned what it
// did". No serials is the shard's own idiom for "everything this run owns",
// which is the correct sweep for exactly that case.
sent = null
await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' })
assert.deepEqual(sent.serials, [])
assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), 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)
})

View File

@@ -0,0 +1,349 @@
// module-uo's half of protocol 7 part b (EVENTS_PLAN.md Phase 12b).
//
// What an event BORROWS — five targeted leases over two planes — and the two
// one-shots that are neither borrowed nor owned.
//
// The tests below are the places where the obvious implementation is subtly the
// wrong one and nothing would fail if it were written the other way:
//
// • every callable of a targeted lease must PASS THE TARGET ON. A read that
// dropped it would answer about the wrong spawner, and a restore that
// dropped it would write a baseline onto one
// • a target the shard can no longer read is a REFUSAL at apply time, never a
// value: taking the lease anyway records a fictional baseline and later
// writes it onto whatever next holds that id
// • a target that vanished mid-run is a SUCCESSFUL restore, not a failure —
// there is nothing to give back, and reporting it failed leaves a ledger row
// unresolved for ever over an object that is gone
// • `inForce()` reads the frame's `holds`, which is the only thing that can
// answer for a targeted key: there is no list of spawners to walk
// • a grant that reached NOBODY is a success, because an event nobody attended
// still happened — while a run the shard was never told to count is a 404
// • a non-stackable granted in quantity is refused at BOTH ends
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const uoLinkClient = require('../utils/uoLinkClient')
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
require('./_setup')
const actions = require('../config/uoEventActions')
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
const leaseById = (id) => actions.LEASES.find((l) => l.id === id)
const STUBBED = ['getLeases', 'applyLease', 'releaseLease', 'grantItem', 'saveWorld']
let calls
let frame
const saved = {}
beforeEach(() => {
calls = { leases: [], apply: [], release: [], grant: [], save: [] }
frame = {
leases: [{ key: 'Spawner.MaxCount', kind: 'property', current: '3', held: false }],
holds: [],
}
for (const name of STUBBED) saved[name] = uoLinkClient[name]
saved.listSpawners = shardAtlas.listSpawners
uoLinkClient.getLeases = async (q) => {
calls.leases.push(q)
return { ok: true, status: 200, data: frame }
}
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
uoLinkClient.grantItem = async (b) => {
calls.grant.push(b)
return { ok: true, status: 200, data: { granted: 2, missed: [] } }
}
uoLinkClient.saveWorld = async (b) => { calls.save.push(b); return { ok: true, status: 200, data: {} } }
shardAtlas.listSpawners = async (opts) => {
calls.spawners = opts
return [
{ uniqueId: 'uid-1', name: 'fel orc fort', facet: 'Felucca', region: 'Britain', maxCount: 9 },
{ uniqueId: 'uid-2', name: null, facet: 'Trammel', region: null, landmark: null, maxCount: 1 },
]
}
})
afterEach(() => {
for (const name of STUBBED) uoLinkClient[name] = saved[name]
shardAtlas.listSpawners = saved.listSpawners
})
// ── The targeted leases ────────────────────────────────────────────────────
test('every callable carries the target through to the shard', async () => {
// The one thing that cannot be got wrong quietly. Core composes the ledger ref
// as `<lease id>#<target>` and hands the target back on every call; a callable
// that ignored it would read, apply to and restore whichever spawner the shard
// happened to answer about, and nothing here or there would report an error.
const lease = leaseById('uo.spawner.maxcount')
const target = '003f11b8-9bfa-4587-991e-ca263004efe6'
const read = await lease.read({ target })
assert.deepEqual(read, { ok: true, value: '3' })
assert.deepEqual(calls.leases[0], { key: 'Spawner.MaxCount', target })
await lease.apply('30', new Date(Date.now() + 600_000), { target })
assert.equal(calls.apply[0].key, 'Spawner.MaxCount')
assert.equal(calls.apply[0].target, target)
// A DURATION, not the deadline — 11b's rule, unchanged by targeting. A shard
// whose clock runs fast would restore an absolute deadline the instant it
// took it.
assert.ok(calls.apply[0].holdMs > 0 && calls.apply[0].holdMs <= 600_000)
await lease.restore('3', { expected: '30', target })
assert.deepEqual(calls.release[0], {
key: 'Spawner.MaxCount',
target,
expected: '30',
baseline: '3',
})
})
test('a target the shard cannot read refuses the lease rather than defaulting', async () => {
// The failure this guards is silent and permanent: a lease taken over a
// spawner that is not there records whatever came back as the baseline, and
// teardown then WRITES that baseline onto whatever next holds the id.
frame.leases = [{ key: 'Spawner.MaxCount', unreadable: "nothing on this shard has serial 0x99" }]
const refused = await leaseById('uo.spawner.maxcount').read({ target: '0x99' })
assert.equal(refused.ok, false)
assert.match(refused.error, /nothing on this shard has serial/)
// A row with neither a value nor a reason is refused too. The shard should
// always send one of them, and "it sent neither" must not read as zero.
frame.leases = [{ key: 'Spawner.MaxCount' }]
const empty = await leaseById('uo.spawner.maxcount').read({ target: 'uid-1' })
assert.equal(empty.ok, false)
assert.match(empty.error, /could not read/)
})
test('a target that vanished mid-run is a successful restore, not a failure', async () => {
// 12a's `gone` in the lease plane's vocabulary. Somebody deleted the spawner
// while the run held it: there is nothing to give back and nothing is owed.
// Reported as a failure it would sit in the ledger unresolved for ever, over
// an object that no longer exists — and every sweep would try again.
uoLinkClient.releaseLease = async () => ({
ok: true,
status: 200,
data: { kind: 'lease.ok', released: true, targetGone: true, reason: 'that object has been deleted' },
})
const done = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
assert.deepEqual(done, { ok: true })
})
test('drift is still drift, and is still not an error', async () => {
// Unchanged from 11b and asserted again because targeting rewrote the whole
// callable: core records drift as a distinct SUCCESSFUL outcome, so an error
// here would put the row on the retry ladder and eventually report the lease
// as vanished rather than as somebody having moved it.
uoLinkClient.releaseLease = async () => ({
ok: true,
status: 200,
data: { kind: 'lease.drifted', current: '12' },
})
const drifted = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
assert.deepEqual(drifted, { ok: false, drifted: true, current: '12' })
})
test('inForce reads the holds list, which is the only thing that can answer', async () => {
// A catalog walk can enumerate the KEYS but never the holds on a targeted one
// — there is no list of spawners to walk — so the frame carries every hold the
// shard has, and this is what reads it.
const lease = leaseById('uo.spawner.maxcount')
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: false })
frame.holds = [{ key: 'Spawner.MaxCount', target: 'uid-1', runId: '7' }]
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: true })
// ...and it is the hold on THIS target, not any hold on the key. A run holding
// one spawner must not make every other spawner look leased.
assert.deepEqual(await lease.inForce({ target: 'uid-2' }), { ok: true, held: false })
})
test('a shard that cannot answer is never read as "the lease is gone"', async () => {
// Core's posture everywhere: "I could not ask" must not be recorded as "it is
// gone", because the second orphans the row and stops teardown ever trying.
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, data: null })
const answer = await leaseById('uo.spawner.maxcount').inForce({ target: 'uid-1' })
assert.equal(answer.ok, false)
})
test('the seasonal lease is a three-value enum over eight events', () => {
// §G called `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and
// had it backwards: `EventStatus` has three values, `EventType` has nine
// entries — and one of those nine is excluded, so it is eight.
const lease = leaseById('uo.seasonal.status')
assert.equal(lease.type, 'string')
assert.deepEqual(lease.values, ['Inactive', 'Active', 'Seasonal'])
assert.equal(actions.SEASONAL_EVENTS.length, 8)
// TreasuresOfTokuno reads its own era rather than this status, so leasing it
// would apply cleanly and change nothing — §N10's "a capability that lies",
// and the one instance no runtime probe can catch.
assert.ok(!actions.SEASONAL_EVENTS.includes('TreasuresOfTokuno'))
})
test('every targeted lease bounds what it can hold', () => {
// §F requires a range on the numeric types because, unlike a cap, a bad lease
// value is in force the moment it is applied. Restated over the five because
// they are built by a shared factory: one missing bound would be missing in a
// way no single declaration shows.
for (const lease of actions.LEASES) {
if (lease.id === 'uo.playercaps.skillcap') continue
assert.ok(lease.maxDurationMs > 0, `${lease.id} has no duration bound`)
if (lease.type === 'int' || lease.type === 'float') {
assert.ok(Number.isFinite(lease.min) && Number.isFinite(lease.max), `${lease.id} has no range`)
assert.ok(lease.min <= lease.max, `${lease.id} has min above max`)
}
if (lease.type === 'string') {
assert.ok(Array.isArray(lease.values) && lease.values.length, `${lease.id} has no value set`)
}
}
})
// ── The spawner source ─────────────────────────────────────────────────────
test('the spawner source searches, and says so', async () => {
// The first source with more entries than a dropdown holds: 6,707 spawn points
// against MAX_OPTIONS' 2,000. A flat list would drop two thirds of the world
// and say nothing about which two thirds.
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.spawners')
assert.equal(source.searchable, true)
const rows = await source.resolve({ q: 'orc' })
assert.equal(calls.spawners.q, 'orc')
assert.equal(calls.spawners.limit, actions.SPAWNER_OPTIONS)
// The value is the UniqueId, because it is the only name for one particular
// spawner that exists off the shard.
assert.deepEqual(rows[0], { value: 'uid-1', label: 'fel orc fort', group: 'Britain' })
// A nameless spawner still answers, labelled by its id. It is still a spawner
// somebody may need to turn down, and dropping it would be a dropdown quietly
// missing rows again.
assert.deepEqual(rows[1], { value: 'uid-2', label: 'uid-2', group: 'Trammel' })
})
// ── The one-shots ──────────────────────────────────────────────────────────
test('a grant sends a run and never a recipient list', async () => {
// The shard has held this run's participation ledger since it opened, keyed by
// the same serials core stores as `member_key`. Sending a list would put it on
// the wire twice with a window in which the two disagree — and would have
// needed a core surface handing a module core's own participants.
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 500, where: 'bank' },
})
assert.equal(out.ok, true)
assert.deepEqual(calls.grant[0], {
runId: 7,
item: 'gold',
amount: 500,
hue: undefined,
name: undefined,
where: 'bank',
idempotencyKey: 'k',
})
assert.equal(out.detail.granted, 2)
})
test('a grant that reached nobody is a success', async () => {
// An event nobody attended still happened. Reported as a failure the run would
// retry against a ledger that will be just as empty next time, and pause. The
// shard draws the distinction that matters: a run it was never told to count
// is a 404, which fails below.
uoLinkClient.grantItem = async () => ({ ok: true, status: 200, data: { granted: 0, missed: [] } })
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 1 },
})
assert.equal(out.ok, true)
assert.equal(out.detail.granted, 0)
uoLinkClient.grantItem = async () => ({
ok: false,
status: 404,
data: { reason: 'run 7 has no participation ledger open on this shard' },
})
const missing = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 1 },
})
assert.equal(missing.ok, false)
// 404 is permanent: the ledger will not appear because we asked again.
assert.equal(missing.retry, false)
})
test('a non-stackable granted in quantity is refused before the wire', async () => {
// Five cloaks would be five items — five chances to overflow a backpack
// halfway through with no way to say which half landed. Refused here so the
// author sees it on the form, and refused again on the shard because this copy
// of the allowlist is the one that can be wrong.
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'cloak', amount: 3 },
})
assert.equal(out.ok, false)
assert.equal(out.retry, false)
assert.match(out.error, /does not stack/)
assert.equal(calls.grant.length, 0)
const unknown = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'castle', amount: 1 },
})
assert.equal(unknown.ok, false)
assert.equal(unknown.retry, false)
assert.equal(calls.grant.length, 0)
})
test('a grant is retryable, and protocol 6 is the reason', async () => {
// §G called a grant un-retryable because a lost acknowledgement and a grant
// that never applied were the same event — the argument that made
// `uo.broadcast` answer `retry: false` in Phase 9. An idempotency key closes
// it: a repeat is answered by the original reply, so a retried grant cannot be
// one winner receiving two.
uoLinkClient.grantItem = async () => ({ ok: false, status: 503, data: null })
const out = await byId('uo.item.grant').perform({
runId: 7,
idempotencyKey: 'k',
params: { item: 'gold', amount: 1 },
})
assert.equal(out.ok, false)
assert.notEqual(out.retry, false)
// And the action declares itself irreversible, which is the honest class: the
// world is altered and cannot be put back.
assert.equal(byId('uo.item.grant').risk, 'irreversible')
assert.equal(byId('uo.item.grant').reversible, 'none')
})
test('a save refused for coming too soon is retried, not abandoned', async () => {
// 429 is the shard's rate limit and is the one refusal on this plane that
// waiting fixes. It is deliberately not in PERMANENT_STATUSES, so a phase
// boundary is retried rather than dropped.
assert.ok(!actions.PERMANENT_STATUSES.has(429))
uoLinkClient.saveWorld = async () => ({
ok: false,
status: 429,
data: { reason: 'this shard saves at most every 300 seconds, and the last save was 12 seconds ago' },
})
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
assert.equal(out.ok, false)
assert.notEqual(out.retry, false)
})
test('a save reports only that it started', async () => {
// What actually happened rides `world.save.before`/`after` on the event stream.
// Asserting anything more here would be asserting something the reply does not
// know.
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
assert.deepEqual(out, { ok: true, detail: { started: true } })
assert.deepEqual(calls.save[0], { idempotencyKey: 'k' })
})

View File

@@ -351,10 +351,17 @@ function tagValue(block, name) {
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
* flat, so a per-record regex sweep is both correct and cheap.
*
* Only the fields the site can actually show are kept. Everything to do with
* triggering, refractory windows, proximity, sequential spawning, sounds and
* `UniqueId` is dropped here rather than downstream — that is what holds the
* committed artifact under 1 MB.
* Only the fields the site can actually use are kept. Everything to do with
* triggering, refractory windows, proximity, sequential spawning and sounds is
* dropped here rather than downstream, which is what keeps the parsed atlas
* small.
*
* **`UniqueId` was on that list until Phase 12b and is now kept**, because a
* property lease has to name one particular spawner and this is the only name
* for one that exists off-shard. The line that justified dropping it cited a
* committed artifact; there is no committed artifact — `spawnAtlasSource.js`
* says so in its own header ("nothing is precomputed and committed") — so the
* only real cost was ~37 bytes a row in a table, and it bought a dropdown.
*
* NOTE: the facet comes from each record's own `<Map>`, never from the file
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
@@ -389,6 +396,14 @@ function parsePoints(source) {
points.push({
name: tagValue(block, 'Name'),
// **Kept from Phase 12b, having been discarded since the atlas shipped.**
// It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files
// and carries it on the live spawner — so it is the ONE way an authoring
// form can name a particular spawner without the shard being up. A serial
// cannot do that job: serials are assigned when the world is built and
// nothing off-shard knows them, which is why a property lease that could
// only be addressed by serial could have no dropdown at all.
uniqueId: tagValue(block, 'UniqueId'),
facet,
x: toInt(tagValue(block, 'X')),
y: toInt(tagValue(block, 'Y')),
@@ -548,6 +563,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 +732,7 @@ module.exports = {
parseRegions,
parseLocations,
parseChampions,
parseDecoration,
buildPlacementIndex,
resolveRegion,
facetKey,

View File

@@ -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 }
}
@@ -140,8 +175,13 @@ function hashSources(root) {
*
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
* seconds in the source, decided by `DelayInSec`).
* 3 — the decoration index, from `Data/Decoration/**\/*.cfg`.
* 4 — a spawn point keeps its `UniqueId`, which is what a property lease
* targets (Phase 12b). The bump is what re-reads a tree the boot path
* would otherwise skip on an unchanged hash — the source files have not
* changed, only what is kept from them.
*/
const PARSER_VERSION = 2
const PARSER_VERSION = 4
/** True when two source fingerprints describe the same tree. */
function sameSources(a, b) {
@@ -294,6 +334,43 @@ 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.
//
// **Keyed case-INSENSITIVELY, because the decoration files disagree with
// themselves about casing.** Stock 57.4 names four types under two spellings
// each — `CheckerBoard`/`Checkerboard`, `ChessBoard`/`Chessboard`,
// `MetalChest`/`Metalchest`, `SpinningWheelEastAddon`/`SpinningwheelEastAddon`
// — and in every pair exactly one is a real class, the other a mis-cased line
// the shard's own loader resolves anyway. A case-sensitive Map keeps both, and
// then `shard_decor_types.type` (a PRIMARY KEY under MariaDB's default
// `..._ai_ci` collation, which folds case) rejects the second row and takes the
// WHOLE import transaction down with it. That is not a decoration bug: with no
// atlas, every option source answers empty and no world verb can be authored at
// all. The shard end of this feature already knew — `BridgeWorld.cs` resolves a
// decor type with `FindTypeByName(name, ignoreCase: true)` and says why — so
// folding here is the two ends agreeing rather than a new rule.
//
// The first spelling seen wins, exactly as the first item id does. Either
// spelling resolves on the shard, so which one survives is cosmetic.
const decorUses = new Map()
for (const file of files) {
if (!file.label.startsWith('Data/Decoration/')) continue
for (const entry of parseDecoration(file.text)) {
const key = entry.type.toLowerCase()
const seen = decorUses.get(key)
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(key, { 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 +388,7 @@ function buildAtlas(root, options = {}) {
regions: regions.length,
landmarks: landmarks.length,
champions: champions.length,
decor: decor.length,
unresolvedPoints: unresolved,
},
source,
@@ -321,6 +399,7 @@ function buildAtlas(root, options = {}) {
landmarks,
champions,
points,
decor,
}
}

View File

@@ -236,28 +236,45 @@ const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
// holding it. One read serves both questions core asks — `read()` wants the
// current value, `inForce()` wants to know whether the shard still has a record
// of the hold — so a lease costs one round trip, not two.
const getLeases = () => call('/lease')
// **A targeted lease must name its target here** (protocol 7 part b). A key like
// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no
// single `current` and the catalog walk cannot fill one in — while `read()` needs
// exactly one value for exactly one target before it applies anything. Naming both
// narrows the frame to that row and fills it.
//
// The frame also carries `holds`: every hold this shard has, whatever key or
// target. A catalog walk enumerates the KEYS but can never enumerate the holds on
// a targeted one — there is no list of spawners to walk — so `inForce()` reads
// that rather than the row's `held` flag.
const getLeases = ({ key, target } = {}) => {
const params = new URLSearchParams()
if (key) params.set('key', key)
if (target) params.set('target', target)
const query = params.toString()
return call(query ? `/lease?${query}` : '/lease')
}
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
// computed here and honoured there is a deadline measured against two clocks, and
// a shard running ten minutes fast would restore a ten-minute lease the moment it
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
const applyLease = ({ key, value, holdMs, untilMs, runId, idempotencyKey }) =>
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
call('/lease', {
method: 'POST',
body: { key, value: String(value), holdMs, untilMs, runId, idempotencyKey },
body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey },
})
// `expected` is what this run applied and `baseline` is what to put back, both out
// of core's ledger rather than the shard's memory — so a release still works after
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
// reverts every config lease by design) answers honestly instead of refusing.
const releaseLease = ({ key, expected, baseline, idempotencyKey }) =>
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
call('/lease/release', {
method: 'POST',
body: {
key,
target,
expected: expected == null ? undefined : String(expected),
baseline: baseline == null ? undefined : String(baseline),
idempotencyKey,
@@ -289,11 +306,78 @@ 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.
//
// **It takes no idempotency key, and the parameter is gone rather than optional.**
// It used to accept one, and `revertOwned` passed the step's — the key the SPAWN
// went out under. The shard's at-most-once store is keyed on the key alone, so the
// despawn was answered with the spawn's stored reply and nothing was ever deleted.
// A repeat despawn needs no key: the second pass answers `gone`, which both ends
// already treat as a success. Removed from the signature so it cannot be handed
// one again by accident.
const despawnWorld = ({ runId, serials }) =>
call(`/world/${encodeURIComponent(runId)}/despawn`, {
method: 'POST',
body: { serials },
})
// ── Help-page (support) queue commands (§6) ────────────────────────────────
const respondPage = (pageId, { message, close }) =>
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ────────────
//
// Neither owned nor borrowed: done is done. Both are gated on the shard by the
// same `Bridge.EventsEnabled` as the rest of the plane.
// What this shard will actually build, with the bounds it will build within. The
// module holds the same allowlist for its dropdown, so the form still works with
// the shard down; this is what is true when that copy is wrong.
const getGrantCatalog = () => call('/items')
// **The recipients are not sent.** The shard has held this run's participation
// ledger since it opened, keyed by the same character serials core stores as
// `member_key`, so the grant names a run and the shard resolves who was there.
// Sending a list would put the same list on the wire twice with a window in which
// the two disagree — and would have needed a core surface handing a module core's
// own participants.
const grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) =>
call('/items/grant', {
method: 'POST',
body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey },
})
// Starts a save. What actually happened rides `world.save.before`/`after` on the
// event stream, which have been there since protocol 2 — so this asserts only that
// the save was started, and a caller that needs the completion watches the feed it
// is already connected to.
const saveWorld = ({ idempotencyKey } = {}) =>
call('/world/save', { method: 'POST', body: { idempotencyKey } })
module.exports = {
TIMEOUT_MS,
invalidateConfig,
@@ -328,6 +412,12 @@ module.exports = {
openParticipation,
snapshotParticipation,
closeParticipation,
spawnWorld,
ownedWorld,
despawnWorld,
getGrantCatalog,
grantItem,
saveWorld,
adminKick,
adminBan,
adminUnban,