5c35b4fe9751c2b2f7958138eebc6d579624f85a
19 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| d4d5989926 |
feat(atlas): the spawn atlas reads the shard, not the shard's filesystem (Phase 7)
`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
|
|||
| c6c51b190d |
feat(assets): a creature's picture is whichever action has one (Phase 6)
The shard's catalogue can now answer for 73 bodies it used to report absent —
they have no art at action 0 and real art at a later one, and their key says
which (`body/820/a23` is a horse). This side stores that action and stops
assuming `a0` anywhere.
The atlas join is the part that mattered. It read
a.asset_key = CONCAT('body/', b.body, '/a0')
which would have silently dropped exactly the creatures this phase adds. It now
reads the row's own action, with COALESCE for rows written before the column
existed — a NULL inside CONCAT makes the whole comparison NULL, which would have
taken every portrait off the site on upgrade with the database perfectly correct
and nothing in any log. It still matches at most one row per slug: a deeper key
(`body/820/a23/f4`) does not equal the catalogue key.
Verified against a real MariaDB with the live shard's own 1,095-row manifest: the
ALTER applies to an installed-shape table and is idempotent, the horse joins to
its a23 picture, a pre-phase-6 NULL-action row keeps its portrait, and a stored
frame key does not become a second candidate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| f335531538 |
feat(assets): item pictures on the marketplace and the character sheet (Phase 5)
Both places this site already knew an item's (ItemID, hue) and could only print it as text now show the picture, hued the way the client would draw it. The shard does the hueing: whether a hue repaints every pixel or only the grey ones is a flag in `tiledata.mul`, which a browser has no way to read. **Ingest warms; the route only serves** (org lead, 2026-09-11). A page never waits on the shard and never causes a fetch -- it renders what is stored and leaves out what is not, which is the state every install was in before this phase. Fetching happens behind that, on a timer, from the keys the site's own rows name. The alternative, fetching on first request, was rejected on one number: the shard's asset plane serves ONE request at a time, so a URL that fetched would let any visitor walk 49,152 ids times 3,000 hues through that slot and park an operator's own import behind it. The wanted set is DERIVED (`SELECT DISTINCT item_id, hue`) rather than queued, so it is self-healing: a restart loses nothing, and a key stops being wanted the moment the vendor row naming it is deleted. The in-memory hint set on top is only for the character sheet, which is fetched live from the shard and stored nowhere -- nothing on disk would ever name those keys. Staleness without a manifest (§7): every row records the shard's `catalog` id, a hash of the files that decide its bytes. A client patch changes it and a restart does not, so "is this out of date?" is a per-row question -- and pictures nobody looks at any more are simply never re-fetched, which is why this is lazy rather than a sweep. `shard_asset_meta` is deliberately NOT written here: it is the body catalogue's singleton, and a warm pass touching it would tell the body import that a client it never looked at is unchanged. A key the shard has no art for writes no row at all. An empty row would make the key held and it would never be asked again -- including after the operator patches in the graphic that was missing. `assets.sources` now reports which families an overlay serves, so an overlay older than phase 5 is one reported state with a sentence naming the fix, instead of a refusal per pass forever with no picture ever appearing. 688 server tests pass (14 new); client builds; the frozen manifest regenerates with one added route, all documented, no core URL moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| a194ec68e0 |
feat(assets): creature artwork from the shard's own client (Phase 3)
Until now the only way a creature got a picture on this site was for an
operator to open UOFiddler on a desktop, export sprites by hand, copy them to
the web host and write a spawnAtlas.art.json naming each one. Almost nobody
did, so shard_spawn_creatures.art was NULL on every install.
The shard has had those files the whole time. Admin -> Shard -> Import now
walks its asset manifest, fetches only the sprites whose hash changed, writes
them under uploads/atlas/, asks the shard for a body id per atlas creature
(§8: it CONSTRUCTS the creature and reads Body.BodyID, which is the only thing
that is right for a shard's own custom creatures) and points each creature at
its picture. On a stock client that is 787 portraits, about a megabyte.
**The one thing v8.md §12 got wrong, and it is not cosmetic.** It says
`shard_spawn_creatures.art` "starts being filled by the import". That table is
emptied and refilled by replaceAtlas on EVERY atlas refresh, and a refresh runs
on every boot -- so a filename stored there would be destroyed by an ordinary
re-parse of the ServUO tree, with the next Update finding the client files
unchanged, reporting "nothing to do", and never restoring it. Nothing would
report a fault; the pictures would just be gone.
So the assets and the body map live in their own tables outside that blast
radius, and applyAtlas re-derives `art` on the way past as
`{ ...derived, ...operatorMap }` -- which is also the one place "the operator's
own artwork wins" is enforced, on every rebuild rather than only at import.
Smaller decisions worth not rediscovering:
- The derivation joins on the catalogue KEY, not on the body id. The simpler
join is correct today and stops being correct the moment phase 6 adds
body/400/a2/f0, at which point one slug matches dozens of rows.
- Filenames are content-addressed. A stable name overwritten in place leaves
every browser and CDN serving the previous client's sprite, with the database
row perfectly correct.
- An unchanged key whose FILE is missing is fetched again. The row and the disk
can disagree (a wiped uploads volume, a restore from a dump), and a broken
image on a creature page is worse than one re-fetched sprite.
- A key the shard cannot render is not a failure. Two thirds of the playable
ghost and gargoyle bodies have no art on a stock client, and an import that
reported eight failures every time would teach an operator to ignore the panel.
- A key that VANISHED from the manifest needs review before anything changes:
an unmounted client volume and a deliberate downgrade look identical here.
23 new tests; 674 server and 42 client tests pass. The SQL was also run against
a real MariaDB, which is what proved the CONCAT join and the singleton CHECK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 893a36618b |
feat(cliloc): import the table from the shard, not from a file someone converted (Phase 2)
The base cliloc table now comes over the bridge. `clilocBridge.js` walks
`GET /cliloc` page by page and the model merges the `custom/` overlays over it —
overlays stay on disk because ServUO has no server-side notion of a custom
cliloc, so there is nothing on the shard to ask for.
**The shard wins whenever uo-link is configured and enabled**, with no mode
setting: there is no version of "which source?" an operator benefits from
answering. A file on disk remains the source only where there is no shard link,
plus a one-off explicit `path` — deprecated, not removed, and unchanged.
**Boot no longer imports on the bridge.** The file path could hash 5 MB locally
and skip in 14 ms; a shard round trip in the boot sequence would be spent
answering "no" on every restart but the one after a client patch — and patching a
client is an operator action, so importing became one. Admin → Shard → Import.
Whatever table is loaded keeps serving until then.
Three checks in the walk, each for a way a shard can hand back a table that looks
complete:
* only `cut: 'end'` finishes it — a short page can equally be a spent budget,
and a truncated table renders some items named and some not, which is exactly
what NO table looks like;
* the cursor must advance, or the walk stops rather than spinning;
* every page echoes the source's size and mtime, so a client patched mid-import
is refused outright rather than stitched from two files.
**The base is exempt from the vanished-source rule**, which is an upgrade detail
rather than a preference: an install that used the file pipeline carries its base
file's label in the stored fingerprint, and on the bridge that label is *supposed*
to disappear. Counting it as vanished would demand an approval for a change the
upgrade itself made. Overlays keep the rule in full.
**The protocol pin moves 7 → 8** — the third declaration site, and the one
nothing enforces. Phase 1 moved the sidecar and the overlay together because the
installer refuses a mismatched bundle; this one has to be moved by hand, in the
phase that first calls a protocol-8 route. The schema block above it is the
record of what forgetting costs: two phases of every REST call answered 409.
Verified against a live shard, sidecar and site: 12 pages, 67,496 rows imported
in 1.68 s, the operator's three-row overlay overriding stock strings on top of
it, and the next import correctly `unchanged`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
|
|||
| 8def6e19f4 |
fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)
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 |
|||
| 10fde87724 |
feat(events): what an author borrows, and two one-shots (Phase 12b)
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 |
|||
| 89be9d6a4e |
feat(events): the five world verbs an author sees (Phase 12a)
`uo.creature.spawn`, `uo.boss.spawn`, `uo.npc.place`, `uo.gate.open` and `uo.decor.place`, over protocol 7's one command family. Five actions because five is what an author has; one `perform`/`revert`/`reconcile` because on the wire they are one thing. Five new budget dimensions -- `uo.creatures`, `uo.bosses`, `uo.npcs`, `uo.decor`, `uo.gate.minutes` -- all declared by THIS MODULE (org lead, 2026-09-07). Core meters whatever dimensions a module declares and holds no UO knowledge, which is the whole of what MODULE_API means by game-agnostic. A gate is priced in minutes rather than in gates: one standing all day and twelve standing five minutes each are not the same imposition on a world. `reconcile()` ASKS the shard, and is the one place in this file that must not use `reconcileByBootId`. A crier line lives in shard memory, so a changed `bootId` IS proof it is gone; a spawned creature is in the world SAVE and survives the restart the stamp would report it lost by. Anything `world.owned` does not list is gone -- safe only because the shard's registry and the objects it describes are written by the same save. Teardown reports `gone` as success and `refused` as failed. A creature a player killed is the point of having spawned it, and a run that ended `incomplete` because its event worked would be a report nobody could read. `refused` means the shard denies this run ever owned the serial, so nothing will delete it through this path and the row must land unresolved with a reason. The atlas gains a decoration index, parsed from the shard's own `Data/Decoration/**/*.cfg` -- 120 files, read RECURSIVELY because the real tree nests two deep and a flat read would index a fraction of it while looking like it worked. 313 distinct types. The decor verb resolves through it rather than passing a type name through, which keeps the verb to this shard's own decoration vocabulary AND fetches the item id: `Static` alone accounts for 5031 placements under 1992 different graphics, so a bare type name places the wrong thing. `PARSER_VERSION` -> 3, so an already-imported tree is re-read. Two things the build found in code that had already shipped: `uo.options.creatures` answered with the atlas SLUG -- unique, stable, and not something the shard can build, because a creature is constructed from a ServUO class name and `orc-brute` is not one. The atlas's `name` is the raw type token from the spawn files, so the fix was to stop discarding the half that works. Safe to change because Phase 12a is the source's first consumer; the file said so when it shipped. `uo.npc.place` could not be performed from its own required params. Both ends refuse an oracle with neither a greeting nor a line, but both fields were optional -- so a cross-field rule sat where no authoring form could render it. The greeting is now `required`, which says the same thing in the contract itself. Caught by the existing dry-run sweep, which is a better argument for that test than anything written about it when it shipped. 605 tests pass. `swagger-fragment.json` is stale on `edge` already and this phase adds no route, so it is left alone. Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 |
|||
| 88bfe9310e |
feat(events): one lease and the participation verbs (Phase 11b)
The UO half of protocol 6 part b. No route added, no schema change, no
MODULE_API bump.
`uo.playercaps.skillcap` is the one lease, and the catalog is short because
ServUO made it short: of the 158 non-Bridge `Config.Get` call sites in
`Scripts/`, roughly eight are read live. This one is read inside
`CharacterCreation.cs`'s per-character path, so it is both live and observable --
which is what "proven" has to mean, since the failure an allowlist exists to
prevent is a key that applies cleanly and changes nothing.
Its `apply()` sends a DURATION rather than the deadline: an absolute time
computed here and honoured there is measured against two clocks, and a shard
running ten minutes fast would restore a ten-minute lease the instant it took it.
Its `restore()` turns `lease.drifted` into `{ drifted: true, current }` rather
than an error, because core records drift as a distinct successful outcome and an
error would put the row on the retry ladder. Its `inForce()` asks whether the
shard still HOLDS the lease, never whether the value still matches -- see the
core PR.
`uo.participation.open` / `.collect` count who took part and file them on the
success envelope. `open` is the one resource in this module that must NOT
reconcile by boot stamp: every other resource here lives in shard memory, so a
changed bootId IS the proof it is gone, while the participation ledger is written
into the world save precisely so it survives that restart. It asks instead.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| dc13515927 |
feat(events): send the idempotency key, and declare champ.boss.killed (Phase 11a)
The website's half of protocol 6. Every event-driven write now carries the step's idempotency key, and `uo.broadcast` stops being un-retryable. Phase 9 shipped it answering `retry: false` to everything including a 503 from a shard that was merely restarting, with a comment naming the line that would change when the wire could refuse a repeat. This is that line: it defers to `sidecarFailure`, the same helper its two siblings already used, so the hand-rolled variant that forced every outcome terminal is gone rather than re-tuned. One verb was less idempotent than its own id made it look. Both keyed verbs post under a run-scoped id and a repeat replaces — but `news.add` with `announce: true` makes the criers proclaim the title on every post, so a retry replaced the article silently and proclaimed it again. The key stops the second proclamation. `champ.boss.killed` is mapped to the `champs` feature (rule 2 would otherwise fail it closed to admin), with `damagers` a nested `staff` field rule: the kill is public because a champion falling is what the board is for, the ranked roll of who was strong enough to fell it is not. `uo.champ.boss_killed` is declared as a trigger — which is what makes it usable as an event PHASE CONDITION, since a condition is written over a trigger firing — and it carries `damagerCount`, never a damager name, because a trigger variable reaches mail an operator may address to every subscriber. Its seeded rule is its own group, `champ-boss-killed-v1`: `triggers-v1` is stamped once under a settings guard, so appending a 27th entry would have reached fresh installs and nothing else. It also ships email+inapp and NOT push, and the comment says why — no trigger in this module is also a registered stream, so no engagement rule here can push. That is pre-existing in twenty rules and flagged rather than fixed; this one declines to be the twenty-first. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 021f191f65 |
fix(events): three defects the live rig found, two of them data loss
The whole-rig walk (ServUO + sidecar + website) against a real two-phase event.
- **A WS reconnect would have orphaned every live resource.** The backfill
replays the last several `server.hello` frames in order — this rig saw three,
each with a different `bootId` — so every replayed frame reads as a restart,
and the intermediate ones compare a resource stamped with the CURRENT boot
against a boot that ended hours ago. The row is then `orphaned`: a live crier
line core will never take down again, lost to nothing worse than the website
reconnecting. Gated on `!fromBackfill`, the rule the engagement fan-out and
the SSE broadcast beside it already state. The website-was-down case is not
missed — core asks every module at its own boot.
- **The shard explains its refusals and the run log dropped the explanation.**
A 403 body reads `{"reason":"admin write plane disabled"}`; `legError` looks
for `data.message`, finds nothing, and reports "sidecar responded 403". For a
staff member clicking a button that is survivable. For an event that ran at
four in the morning the run log is the only place anyone will learn why.
- **The "not retried" clause explained the wrong thing on a permanent status.**
A 403 will not succeed on any attempt, so telling an operator it was not
retried "because a repeat would announce twice" points them at a policy
decision instead of at the switch they have to flip. The clause is now added
only where a retry was genuinely given up, and 403/404 join the statuses the
keyed verbs treat as terminal.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 57419111e6 |
feat(events): UO wave 1 — the verbs that need no protocol change (Phase 9)
module-uo registers its first event actions: `uo.broadcast`, `uo.towncrier.post` and `uo.news.post`, plus the `uo.broadcasts` budget dimension and the three spawn-atlas option sources. The write plane they use has existed since protocol 2.1; what is new is the declaration that lets the event engine drive it unattended. Three things the tree corrected about the plan: - The plan's `on_failure: 'skip'` for `uo.broadcast` is already the default for `risk: 'notify'`, and `on_failure` is what happens AFTER the retries. The lever a module actually has is the failure envelope, so the action answers `retry: false` to everything — and every action declares `budgetMs: 15000`, because core's 10s default deadline fires before `uoLinkClient`'s 12s timeout and `classify()` answers `retry` for a timeout without asking the module. Without the budget the retry refusal is unreachable. - `reconcile()` needs no protocol work. A shard restart wipes both the crier lines and an event's news article, so `perform()` stamps the shard `bootId` into the resource payload and `reconcile()` reports in force exactly the rows whose stamp still matches — correct for the module's own trigger and for core's boot sweep alike. `shardIngest` fires `ctx.events.reconcile()` on a changed `bootId`, after `recordStatus` so the comparison reads the new boot. - Event articles post under `evt-<idempotencyKey>`, because `newsGump.js` uses the bare website post id and re-pushes that set on every reconnect. `ci/core-ref.json` moves to a website `edge` sha for the length of this workstream: `registerEventActions` exists only from MODULE_API 1.10.0, so under the old `main` pin the module does not load at all. Verified locally — the frozen-manifest rig passes against the new pin. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 849d4b10e8 |
fix(engagement): four defects the Phase 11b live walk found, and the 26th trigger
Needs website#<core> (the cooldown key and the seed-rule ceiling). 1. Every owner-audienced trigger reached NOBODY. `resolveTarget` read `link.user_id`; the model's `toSafe` returns `userId`. So the whole flagship family -- houses, vendors, logins, unlinks, deaths, the governor's letter -- resolved to null and looked exactly like the ordinary unlinked-account case, which the code treats as normal and deliberately does not log. The test fake returned `user_id` and therefore agreed with the bug, while `shardStreams.test.js`'s fake next door -- same model, the path this file says it copies -- returned `userId`. The fake is now built by running the real `toSafe` over a stubbed db row, so the shape is not a hand-written opinion. 2. `uo.house.refreshed`, the 26th trigger (the org lead's decision 11). The warning's rule carries `delay_seconds: 900` so a player who repairs the house inside the quarter-hour is never told it is in peril -- and nothing could cancel it: `cancel_on` named only the collapse. The wire had carried the transition all along; the mapper returned early on it. It fires on `Ageless` as well as `LikeNew`, and `Ageless` is the common case: a condemned house cannot be refreshed at all (`RefreshDecay()` refuses `DecayType.Condemned`), so the rescue is the owner logging in, and their newest house then reads `Ageless`. Ships a body and a seeded (disabled) rule of its own; the cancellation is read off the WARNING's rule and works whether or not the new one is enabled. 3. Every call-to-action in every in-universe body was a dead link, from two independent mistakes. The client router prefixes a module's routes with its ID (`/uo/houses`), not with module.json's `mounts` (`/shard/...`), so every declared `example` was a 404 -- and an example is what the template editor previews and test-sends with. And no `url` variable was ever populated by the mapper, so the buttons rendered with an empty href and dropped out of the text part entirely. Both now read `config/clientPaths.js`. Two tests close it. 4. A raw wire timestamp was signing off the Merchants' Guild's letter (`2026-09-02T04:06:43.8397548Z`, mid-sentence). Core has no interpolation filters by design, so the readable form is assembled in the mapper and arrives as its own variable; the machine value stays, because an operator writes `is at most` conditions against it. Also fixes a latent flake: `hoursRemaining` floors a live clock, so a fixture at a whole number asserted 19 or 20 depending on sub-millisecond timing. 527 module tests green (3 new). Proved end to end against real ServUO + the release sidecar + a live SMTP catcher; see docs#<docs>. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 50a89b48e2 |
feat(engagement): sixteen in-universe bodies, 25 seeded rules, the governor's letter (Phase 11b)
11a declared the triggers; this is the content behind them. Ships through
core's new api.registerEngagementSeeds (MODULE_API 1.9.0): 32 templates and 25
rules, every rule enabled = 0.
THE VOICE (decision 8). The game-powered families read from inside Britannia,
with a per-family in-fiction sender rather than one voice across all sixteen —
Lord Blackthorn's court writes about the crown's business (the seat, the ballot)
and nothing else, because a shard where Blackthorn writes to you personally about
a champion spawn is a shard where the letter about your governorship means
nothing. The Office of Deeds has houses, the Merchants' Guild vendors, a herald
guilds, the town crier champion spawns, a guildmaster skills and quests, the
Chronicler deaths, the keeper of the rolls leaderboards.
WHAT STAYS PLAIN (decision 9). Nine of the 25 point at core's notify.event /
inapp.event and author nothing, and the line is drawn where fiction costs
something real: a failed-login notice written as "a stranger sought entry to thy
account" is indistinguishable in register from the phishing mail it warns about,
and a moderator reading uo.cheat.detected at 2am wants a name, a rule and a
timestamp rather than a scroll. Both account-security triggers, server up/down,
and the five staff/admin-ceiling ones.
THE GOVERNOR'S LETTER (decision 10) — uo.governor.appointed, the 25th trigger.
§8.6 records that uo.points.rank_changed cannot address a person because top[]
names a mobile serial, and the same reasoning was silently assumed to cover the
governor. It does not: city.update's `governor` is written by BridgeJson.Actor(),
which emits serial, name, acct AND webId. The winner is addressable today with no
protocol change. It fires from the same frame, the same transition and the same
never-on-first-sight guard as uo.governor.elected, which stays exactly as
declared — the town's bulletin and the governor's letter are two triggers because
one trigger means one rule means one template, and they are not the same text.
An operator can run either alone.
PRESENTATIONAL FRAGMENTS, because a template has no conditionals by design and an
unset optional interpolates to the empty string. Phase 5a's `forWhom` precedent:
the ternary stays in the mapper and its result arrives as a declared optional.
Two shapes — a LABEL always has a value and carries a sentence's spine
(houseLabel falls back to a seal number); a TRAILING FRAGMENT may be empty and
leads with its own space, so `{{slainBy}}.` closes as "has fallen." either way.
Additive, so no version bump.
A render sweep over all 32 bodies, twice — once with every declared example and
once with required variables only — is what found these. Three defects it caught:
an optional `{{region}}` in a subject line ("A notice concerning thy house at ");
multi-optional ledger lines rendering "On hand: gold. Charged each period:
gold." on a pre-v5 frame, now assembled in the mapper from the parts actually
present, the same argument place() already makes; and a leading trailing-fragment
opening a body with a stray space.
The labels stay `required: false` deliberately — a missing one must never REFUSE
an emit, since a dropped notification is worse than a cosmetic hole — so nothing
at runtime would notice a mapper that forgot one. engagementSeeds.test.js is what
notices.
524 module tests green; check:imports and check:bundle clean. check:swagger
reports STALE from CRLF alone and regenerates byte-identical — no route changed.
Refs docs ENGAGEMENT.md Phase 11b, decisions 8, 9, 10.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 419dee3e49 |
feat(engagement): declare 24 shard triggers and 3 audiences (Phase 11a)
module-uo's half of ENGAGEMENT.md Phase 11: every trigger DECLARATION, the
wire-kind mapping that fires them, and the three registered audiences. No rule
and no template is seeded here -- that is 11b -- so nothing this adds sends
anybody anything until an operator writes a rule.
server/config/shardTriggers.js declares the 24, grouped by the audience kind
each family exercises, and every variable carries the `example` the template
editor previews and test-sends with. Ceilings: 10 `owner`, 2 `members`, 7
`authenticated`, 2 `staff`, 3 `admin` (the value core adds in the same window).
`uo.cheat.detected` at `staff` is the declaration the lattice exists for.
server/utils/shardEngagement.js maps the wire to those ids, hung off
shardIngest.ingest beside the SSE broadcast and the push tickle, and reads like
shardPush.js on purpose -- owner resolution is why neither can be a pure mapper.
Three things live here because a rule cannot express them:
* Transitions. champ.update and city.update are full-state upserts, so without
a per-process tracker a sidecar reconnect reads as twenty spawns starting.
A FIRST sighting is never a transition.
* Thresholds. conditions.js compares a declared variable against a LITERAL, so
"within 24 hours of dismissal" is not expressible; and vendor.listing is a
sweep frame re-emitted on any price change, so per-frame would flood. The
crossing is tracked here and `hoursRemaining` is declared so an operator can
still narrow with `is at most`.
* The members audience. "The members of THIS guild" differs every firing, so
it travels on the envelope as recipientUserIds (Phase 6 decision 2).
**The fan-out runs BEFORE the state write, and that ordering is load-bearing.**
account.unlinked drops the shard_account_links row that names the one person who
needs to be told; house.remove drops the house whose stored ownerAcct is the only
place a collapsed house's owner appears; guild.leave/remove need the roster and
board mirrors to name who left. Resolving afterwards finds nobody, every time.
Four rows of 8.6 deliberately do not ship, each with its reason recorded in
docs (docs#194): uo.market.item_listed (a saved search, no per-user query store),
uo.guild.joined (core's team.member.joined already fires for it -- a UO guild IS
a Team and this module is the provider), uo.link.requested (no addressable
recipient by construction, ~5-minute TTL), and uo.points.rank_changed's personal
half (top[] names a serial, links are keyed by account).
coreApi -> ^1.8.0: the module now calls registerEventTriggers and declares
`ceiling: 'admin'`, so a 1.7.0 core would refuse the ceiling and a 1.6.0 one
would not have the method at all.
39 new tests; 509/509 pass. check:imports, check:bundle and check:swagger clean.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 6a276a7ec3 |
feat(shard): ingest protocol 5 — decay schedule, vendor fees, login result
The website half of the protocol-5 bump. Engagement Phase 10.
Schema — twelve columns and two indexes.
shard_houses gains next_stage, estimated_collapse, decay_period_sec and
dynamic_decay. estimated_collapse is nullable and stays null far more often than
not, deliberately: under dynamic decay ServUO draws each stage at random on entry,
so collapse is knowable only at IDOC. A null means "not knowable", never "not yet
read".
shard_vendors gains owner_acct plus seven fee columns and an index on dismissal_at.
owner_acct is the structural one — the table has carried owner_name since protocol
3, but a character name joins to nothing, and only the game account reaches
shard_account_links. Until now a vendor row named an owner the site could not
resolve to a person. dismissal_at + owner_acct are what let Phase 11's
uo.vendor.expiring find "vendors about to be dismissed" and turn each into a
person, without scanning every shop.
Ingest.
Both new field groups arrive NESTED and are flattened into columns on the way in,
then re-nested on the way out — the same trick shardMarket already uses for
`location`. That is not stylistic: the visibility projection matches literal JSON
keys, so the stored read model and the live wire frame have to spell a group
identically or one admin rule covers only one of the two paths. It also means a
field added inside a group later inherits the group's gate instead of defaulting to
visible; there is a test that adds an imaginary future fee field and asserts exactly
that.
Two write-back asymmetries, both load-bearing:
* ownerName is written ONLY when the frame carries one. house.update also writes
that column, from a different sweep, and a pre-v5 overlay's house.decay carries
no ownerName at all — coalescing to null would let every decay transition erase
a name the registry had already resolved.
* The schedule and fee columns are written UNCONDITIONALLY, including as nulls. A
schedule is a claim about the future and goes stale on its own: roll a shard
back to a pre-v5 overlay, or let a house leave IDOC, and the right stored value
is nothing. A dismissal date nobody is maintaining is worse than none.
dismissalAt is taken from the shard rather than recomputed. The shard resolved it
against ServUO's two vendor systems, whose charge, funds and pay interval all
differ; re-deriving it here would be a second implementation of PlayerVendor's own
rule.
Visibility — three classifications, each chosen rather than inherited.
* house.decay's `schedule` defaults to `anonymous`. The countdown IS the public
IDOC page's content and a house at IDOC is already announced in game. Listed
anyway so a shard that considers a precise collapse time an unfair advantage can
raise it — and one nested rule takes the whole schedule with it.
* vendor.listing's `fees` defaults to `admin`, the only default in the market
feature that does not reproduce prior behaviour, because there is no prior
behaviour to reproduce. Shop name, owner and location are already visible to any
player through the in-game Vendor Search gump, which is the argument for
publishing them. Held gold, daily charge and dismissal date are visible to the
OWNER only, on that vendor's own gump. Publishing them anonymously would be a
new disclosure and a targeting aid — which shops are about to be abandoned, and
how much coin is in each.
* account.login.result is admin-only BY OMISSION. KIND_FEATURE is the map of kinds
an admin may widen, and there is no rung below admin that an IP plus an auth
verdict belongs on. The omission is the decision, and a test says so by name.
owner_acct needs no rule: rule 1 locks it by suffix. And the new columns are in no
REST read model's column list — they exist for Phase 11's server-side trigger and
reach no client at all.
The pin, and the protocol-4 bug seen from the other side.
Both declaration sites go to 5 (the model constant and schema.sql's CREATE default),
plus the one-shot migration, guarded `protocol < 5` so an install that missed an
earlier step is carried the whole way.
The schema test used to assert `DEFAULT 4` at each site. That is exactly how
protocol 4 shipped with the emitters moved and one site left behind: every site
agreed with itself and the test passed. It now reads DEFAULT_PROTOCOL from the
model, so the assertion is "the declarations AGREE", and the one-shot migration
test is written once against the current version instead of being hand-copied per
bump.
470 tests pass, 16 new. Verified end to end on the live rig against a real ServUO
and the release sidecar.
Docs: RunicGateway/docs link/v5.md.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 2fa4d87a40 |
feat(shard): ingest guild rosters and departures (protocol 4)
Protocol 2 gave the guild board a member *count* and nothing else, so the Guilds page could say a guild had 155 members but never who they were, and findGuildForActor deliberately answered only for leaders because membership for rank-and-file was not in the feed at all. Protocol 4 puts it there. `shard_guild_members` holds one row per member per guild, keyed on (guild_id, serial). `guild.roster` replaces a guild's rows; `guild.leave` removes one. A guild.remove now clears the membership too, so a disbanded guild does not leave orphaned rows behind. The chunking needs explaining. A roster over the shard's per-frame cap arrives as several frames carrying seq/more/total. The sidecar reassembles them for its own GET /guilds board, but the live WebSocket feed and the /history backfill both carry the individual frames — so this ingest sees them unreassembled. It copes without buffering, because a table expresses what the sidecar's single JSON column could not: the frame carrying seq 0 clears the guild first, and every frame then upserts its own rows. Upsert rather than insert because the /history backfill replays stored frames on every reconnect, and a redelivery has to be a no-op rather than a duplicate-key error. The cost is a sub-second window during a multi-frame update where the table holds part of a roster; buffering to close it would duplicate the sidecar's reassembly for a projection that is already only as fresh as a 60s sweep. On visibility: both kinds are mapped to the existing `guilds` feature. Without that mapping rule 2 fails an unmapped kind closed to admin-only, which would have quietly kept rosters off the public page forever. Mapping them is safe because a roster is the first frame carrying locked fields inside an ARRAY of actors rather than one nested actor, and the projection walker already recurses into arrays and matches acct/webId by suffix — so a member's account name is stripped below admin by exactly the rule that already strips guild.leader.acct. There is a test for that specifically, because the difference is a public page listing character names versus one publishing 150 account names. `acct`/`web_id` are still stored, since that is what lets a linked member be matched to a site user; they are just never projected below admin. guild.leave is appended to the event log, as the departure counterpart to guild.join and for the same reason — it is what a "so-and-so left" feed reads. guild.roster stays out: it is board state like guild.update, and it is the one fat frame on the wire, so logging it would put a full membership snapshot into shard_events on every membership change. The PUBLIC_KINDS guard test caught the addition, which is what it is for; its expected set now carries a v4 group alongside the v3 one. Refs: docs/website/TEAMS.md Part 12 Phase 1 Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 493cf296ab |
fix(server): own game-account signup, and repair the gate slice 1 broke
`POST /player/shard/account` and its staff twin have answered 500 for every caller since slice 1: the ported controller called `settings.isGameAccountSignupEnabled()`, which is a member of core's settings model and not of `ctx.settings` — three functions, deliberately. The call was `undefined(...)`, the TypeError landed in the catch, and no test reached the branch. The gate now lives on the side that uses it (`utils/gameSignup.js`), which is also where the policy belongs: the setting's own help text names Bridge.cfg and says the shard's SignupMode must agree, and core cannot own a sentence about a UO shard. The admin field moves to this module's Shard page and the derived flag onto `/public/shard/features`, beside the visibility flags the same callers already read. The setting KEY is unchanged. Renaming `game_account_signup` would silently reset every configured instance to `disabled` on upgrade, with players reporting broken signup as the only clue — the same grandfathering as `spawn_atlas_servuo_path` and the seven stream ids. Both regression tests were shown to fail against the bug before it was fixed. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 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>
|