bf9a702cfa5821d4d5cfac6602aea6d22659f539
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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> |
|||
| 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>
|
|||
| 2d1d91e372 |
feat(guilds): /guild, the module's own chat command
The first command through `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1). The definition and the handler both live here; the bot pulls the definition and runs no line of this module. `/guild` and not `/team`, deliberately. Core does not own the word for a Team — that is what deleted its Team pages in phase 3 — so it does not publish the noun in a channel either. Core ships the dispatcher and zero commands. The audience rungs are re-resolved in the handler rather than assumed: a shard that gates guilds to staff does not become public because the question arrived over Discord. The provider's own staleness guard is honoured too, so a stale board answers "not connected" instead of reporting what it still holds, and `resolveUserId` is exported rather than copied so "linked" means here what it means on the roster. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 268449f2a6 |
feat(teams): answer core's Team provider from the guild board
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.
`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.
The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.
**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.
A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.
Two limitations, both honest and both in the code as comments:
- **`rankLabel` is null.** The wire's roster member is the standard actor object
(`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
Inventing a label from the leader flag would be core displaying something this
module made up.
- **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
`GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
on the wire, so the only leadership visible here is the board's single
`leader_serial`. Raising it to the full set is a protocol change, not
something this module can fix.
`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.
`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.
18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.
`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.
411 -> 413 tests, all passing.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 740a677f92 |
feat(server): register the routes, the slot, the leg and the boot hooks
The entry point becomes real: five mount prefixes, the admin.users.detail extension slot, the shard push catalog, the town-crier announce leg and both lifecycle hooks. module.json declares all of it and the loader checks the declaration against what register() actually registers, in both directions. The URLs are byte-identical to the ones core served before the extraction. That is the whole point of moving the code and not the paths: the shipped Android app calls POST /api/v1/admin/shard/kick and the Discord bot reads /api/v1/public/shard/*, and neither knows a module answers now. Require order is load-bearing and the requires are inside register() because of it. Every ported file reaches core through ./core, whose members resolve ctx when called -- but a router does `const express = core.express` at ITS file scope, which runs the moment it is required. Hoisting these to the top of the file breaks the module with an error about ctx being missing, from a file that never mentions it. boot.js takes the eight UO call sites out of core's server.js. One behavioural change, deliberate: uoLinkSocket.start() and the sidecar health probe used to run AFTER the listener bound and now run before it, because onBoot does. start() returns as soon as the reconnecting client is armed, but the probe is a real HTTP call, so it is fired and NOT awaited -- an unreachable sidecar must not hold the site closed. Reporting that the bridge is down is diagnostics; being up is not a precondition for serving a page. router/rateLimits.js builds the market limiter through ctx.middleware.rateLimit, core's factory. The policy is the module's -- only the module knows what its endpoints cost -- and the plumbing is core's, so there is one express-rate-limit in the process and one place a breach is logged. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 5d7668d5ea |
feat(module): the bundle skeleton (phase 3, slice 0)
The first real module. It registers nothing, deliberately: what slice 0 proves
is the delivery path itself, end to end, before a single UO file moves into it.
Server half: module.json, an entry point that takes (ctx, api) and registers
nothing, a test suite built on a fake ctx, and scripts/checkImports.js -- the
MODULE_API.md §5.1 boundary check. Client half: the Vite library build, four
shims re-exporting react / react-dom/client / react-router-dom / jsx-runtime
from window.__rg, an entry that verifies each is identity-equal to core's copy,
and scripts/checkExternals.js. 29 server tests, 9 client tests, both new.
Verified against a real core: the module loads, mounts its zero routes, runs to
`started`, and is published by /api/v1/public/modules. Its chunk serves from
the entry's directory with `Cache-Control: no-cache` while the module's server
source, module.json and package.json all 404. In Chrome, under the enforced
`script-src 'self'`, the chunk evaluates and reports all four shared
dependencies OK, with zero CSP reports and no console errors.
Three findings, each of which had produced a green build that was wrong.
MODULE_API.md §3.6 shows `external` alongside the aliases and they do not
compose. Rollup asks `external` BEFORE Vite's alias resolver runs, so a
specifier in both is marked external and never aliased -- the chunk then ships
bare `import "react"`, which no browser can resolve without an import map, and
CSP forbids one. Built cleanly and emitted exactly that; checkExternals caught
it. So: alias only, `external` empty, and vite.config.js grows a resolution-time
guard that fails the build if a shared dependency resolves into node_modules.
That guard was wrong twice before it worked. Written against Rollup's `load`
hook it never ran -- `load` is first-wins and an earlier plugin had already
claimed the module -- so a deliberately-broken alias produced a 24 kB chunk with
react-router welded in, and a green build. And its forbidden-package list was
derived from the alias list "so the two cannot disagree", which meant deleting
an alias also deleted the guard against what that alias prevented. It states the
contract now, and a test asserts the aliases stay inside it.
checkImports failed on its own documentation the first time it ran: the comment
naming require("../../etc/passwd") as an example of what to catch, and index.js
explaining why the module must never require("express"). A boundary check that
cannot survive being described is one people stop writing comments around. It
strips comments and template literals with a character walk rather than a
regexp, because a URL in a string contains a comment opener and a comment
contains quotes -- and it has its own test suite, since a check never shown to
fail is a check nobody knows the state of.
Co-Authored-By: Claude <noreply@anthropic.com>
|