3139cb43646556540b36f63def25dfae08158f0c
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 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>
|