7 Commits

Author SHA1 Message Date
f9bbc7a90d chore(cutover): sync main into edge before the Asset Bridge cutover
All checks were successful
PR Checks / client-build (pull_request) Successful in 21s
PR Checks / server-tests (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -31s
`edge` was BEHIND `main` by two commits — Module-uo#35 (the atlas keeps its
`UniqueId`, and a landmark option value names one landmark) and the Event
System's core re-pin — so merging `edge` into `main` as the Asset Bridge
cutover would have REVERTED a released fix. Phase 9a's walk measured it:
0 of 6,455 spawners carried a `UniqueId` on an `edge` rig even after the
column existed.

## The one conflict, and why the number had to move

Both sides bumped `PARSER_VERSION` 4 -> 5, for different reasons, and main's
5 is RELEASED in v1.2.2: "a point keeps its `UniqueId`". `edge`'s 5 was
phase 7's canonical label order.

Keeping 5 would have made phase 7's change unreachable. `sameSources` gates
on the tree hash and `currentParser` on the stored number; an install that
imported under v1.2.2 already stores 5, so a phase-7 build declaring 5 would
be called current and would never re-read. That is precisely the trap this
constant exists to defeat, so the merged file carries BOTH notes: 5 is main's
released meaning, 6 is phase 7's, with the renumbering explained in place.

Everything else merged clean and keeps #35's files verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 17:34:13 -05:00
d4d5989926 feat(atlas): the spawn atlas reads the shard, not the shard's filesystem (Phase 7)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 23s
PR Checks / frozen-manifest (pull_request) Successful in 1m8s
PR Checks / client-build (pull_request) Successful in 7m59s
`spawnAtlasSource.js` gains a second backend behind its existing interface
(docs/link/v8.md 10). Where a shard is linked and enabled the tree arrives over
the sidecar; where there is none, a local ServUO tree is read exactly as before.
An explicit --servuo path is an instruction and overrules both.

The parsers do not move. spawnAtlasParse.js is still pure, still fs-free and
still CI-covered without a ServUO tree anywhere near it; `buildFromFiles` is now
where the parse starts, and both readers feed it the same shape.

treeBridge.js walks the manifest and then the chunks. Three of its checks are
not decoration -- each is a way this ends in a tree that LOOKS imported, and XML
is forgiving enough that a mis-assembled spawn file parses cleanly and simply
has fewer spawns in it:

  - every chunk re-declares its address and carries the hash of its own
    uncompressed bytes, and chunks are placed by declared index rather than
    arrival order
  - the whole file is hashed after reassembly against its manifest row
  - the catalog must not move mid-walk, or the import is refused rather than
    stitched out of two trees

Boot does not call the shard. The same answer 17.7 gave the cliloc table, and
the same reasoning: a local tree hashes in ~120 ms and skips, while a round trip
in the boot sequence would answer "no" on every restart that did not follow a
map edit. Editing spawn files is an operator action, so importing is one --
Admin -> Spawn Atlas -> Import. What that costs is real and is said out loud in
the panel, the CLI and the log: an install on the bridge has NO automatic
refresh at all.

Two things the live walk found that the unit tests could not:

  - PARSER_VERSION 4 -> 5. The parse is order-sensitive in one place -- the
    decoration index keeps the FIRST item id it sees for a type -- and the two
    readers agreed on a stock tree by coincidence, since the filesystem reader
    walks each directory with localeCompare while the shard sorts whole relative
    paths. buildFromFiles now sorts by label, ordinally, once, whatever order
    the files arrived in. Identical input, a different answer for a handful of
    types: exactly what the version number exists to push through the hash gate.
    The parity test asserted deepEqual, which ignores key order; it now asserts
    serialised equality too.
  - The source fingerprint is taken over RAW BYTES at both ends. Hashing decoded
    text hashes a UTF-8 re-encoding -- identical for valid UTF-8, different for a
    file that is not, because an undecodable byte becomes U+FFFD and never comes
    back. One Latin-1 character in a creature name would have made the drift gate
    report a change on every import, forever, with the tree untouched.

A 200 from assets.sources also stopped meaning "the client files are on offer":
a shard may now serve its configuration tree while declining to serve its UO
client. Both client-file readers check `assetsEnabled` and say DISABLED, instead
of reading an empty file list as "your client has no cliloc.enu" and sending an
operator to their client install for a setting that lives on their shard.

Measured end to end against a live shard and the real sidecar: 141 files,
11.9 MB, 158 chunks, 3 pages, 1.33 MB on the wire, 512 ms; every file
byte-identical to disk; and the atlas built over the bridge identical to the one
built off it -- 6,455 points, 800 creatures, 387 regions, 558 landmarks,
25 champions, 309 decoration types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 02:00:43 -05:00
d6346996d3 fix(atlas): keep the UniqueId, and make a landmark value name one landmark
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / frozen-manifest (pull_request) Successful in -21s
Two defects the Phase 16b re-verify found in the released v1.2.1 bundle, both
of which make a shipped feature unusable and neither of which any test saw.

## The aggregator discarded the UniqueId

`shard_spawn_points.unique_id` was NULL on all 6,455 rows of a stock 57.4 tree.
`listSpawners` filters `unique_id IS NOT NULL`, so `uo.options.spawners` was an
empty dropdown -- and it is the ONLY option source for the Phase 12b
object-property leases, so no `Spawner.MaxCount` / `MinDelay` / `MaxDelay` lease
could be authored at all, with nothing on the form to say why.

Every part of the path was already right except one line. The spawn files carry
`<UniqueId>` (~6,374 of them), `parsePoints` returns it, the column exists and
the insert passes `p.uniqueId || null`. `buildAtlas` rebuilds each point from an
explicit field list and `uniqueId` was not on it -- the word appears nowhere in
that file. `PARSER_VERSION = 4`'s own note says "a spawn point keeps its
UniqueId, which is what a property lease targets", so the intent shipped as a
comment while the code dropped the field one function later.

`PARSER_VERSION` goes to 5 because the bump is the only thing that re-reads an
already-imported tree: `sameSources` compares the tree's hashes, which have not
changed -- only what is kept from them. Confirmed on the rig, where the boot
after the fix logged `spawn atlas refreshed` on an unchanged tree and the manual
import then correctly answered `unchanged`.

## A landmark option value named 23 places at once

A stock tree has 558 landmarks under 320 distinct `facet/name` pairs.
`Trammel/Entrance` is 23 different dungeons -- Blighted Grove, Covetous, Deceit,
Despise, Destard and so on -- and `landmarkPoint` resolved with `.find()`, so 22
of the 23 were unreachable. An author who picked "Entrance - Destard" got
Blighted Grove, and the run succeeded with no warning. The group was already the
disambiguator: it was shown in the dropdown and left out of the value.

The value is now `facet/group/name`, which is distinct across all 558.
`landmarkPoint` tries that form first and keeps the two-part read as a fallback,
because every event published before this fix stores `facet/name` and a
published version is immutable -- refusing to parse those would break runs
rather than correct them. The fallback keeps the old first-match behaviour
deliberately: it is imprecise in exactly the way it always was, and silently
relocating a live event's spawn point is worse than repeating a known
imprecision. A three-part value whose group is gone REFUSES rather than falling
back to the name, because it asked for one particular place.

## Verification

On the released-artefact rig (installer -> bundle 2026.09.10 -> stock 57.4 tree
-> protocol-7 sidecar -> core at main with this module):

  spawn points     6455 rows, 6364 with a unique_id   (was 0)
  uo.options.spawners   100 options, and `?q=orc` searches them   (was 0)
  uo.options.landmarks  558 options, 558 distinct values          (was 320)
  suite            625 pass, 0 fail

Each new test was confirmed to FAIL without its fix. The atlas one asserts the
field on the AGGREGATOR's output rather than the parser's, which is the whole
point of it -- and the test fixture had no `<UniqueId>` at all until now, which
is exactly why a green suite said nothing. The landmark one asserts an
INEQUALITY between two resolved points rather than a literal value string, so it
survives another change of format as long as two options still address two
places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 20:59:49 -05: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
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
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
fe3251a543 feat(server): port the UO models, utils and schema fragment
The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:06:26 -05:00