Commit Graph

13 Commits

Author SHA1 Message Date
8c4dc0ee93 fix(sidecar): apply rustfmt to the protocol 3.0 cutover code
The release workflow's first gate is `cargo fmt --check`, and the 3.0
cutover merge (2301c57) landed two rustfmt violations in the
`world.ruleset` path, so the run failed before it could build or tag:

- main.rs: the `upsert_ruleset(..)` call fits on one line
- store.rs: the `upsert_ruleset` signature does not

No behavior change — formatting only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 01:40:03 -05:00
480423090a feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors`
table and serves GET /market.

The frame is authoritative for one vendor, so the upsert is a whole-row
overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed,
expires, or its owner switches off the in-game Vendor Search flag — the last of
those is a privacy control, so dropping the row promptly is the point.

Items ride inside the stored blob and are deliberately not normalized into a
vendor_items table. The sidecar's job for the market is outage resilience
(PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side,
where the query surface, the indexes and the cliloc-resolved names already are.

/market is the only PAGED read the sidecar serves, because it is the only board
that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and
`total` comes back so a caller knows when to stop rather than paging until it
sees a short page, which would race a concurrent sweep. Ordering is by SERIAL,
not shop name: a serial is stable while a shop name is renameable, so a rename
mid-walk cannot make a vendor skip or repeat a page.

The route is /market and not /vendors: /vendors/:account next door is the
per-account RPC, and two routes a prefix apart meaning "this player's shops" and
"every shop on the shard" is a readability trap.

Frames are served verbatim, owner names and coordinates included — the sidecar
defines no audiences (v3.md §3.2).

Verified against the live shard: 27 vendors / 1,040 listings ingested from the
plugin, plus a synthetic insert-then-remove confirming the delete path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:14 -05:00
21a1462e62 feat(store): persist points.board and serve it from GET /points
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards as one points.board frame per system; the sidecar folds each into a
projection table and serves them back, so the site's leaderboards page renders
during a shard outage.

  - points_boards(system PK, name, json, updated_t), keyed by the shard's own
    PointsType name. `name` is hoisted only for the ORDER BY.
  - main.rs gains a points.board arm keyed on `system`, alongside the existing
    champ/guild/governor/house/ruleset projections. There is deliberately no
    delete counterpart: the shard's set of point systems is fixed at startup, so
    it emits no points.remove — the same shape the governor board already has.
  - GET /points returns every board ordered by display name; GET /points/:system
    returns one, or 404 when the shard has never published that system. 404 and
    "a published board nobody has scored in yet" (200, empty top) are different
    answers, and the website renders them differently.

Store-backed rather than an RPC for the same reason as the other boards, and it
matters more here: these are standings accumulated over months, so blanking them
during a shard restart reads as data loss rather than as staleness.

PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover
in v3.md §4, not a per-phase change.

cargo build and cargo clippy --all-targets are clean. Smoke-tested against a
driver on the loopback link: two boards stored and served, a re-emitted system
overwriting rather than accumulating, 404 for an unknown system, 401
unauthenticated. Also verified against the real ServUO shard, which fed five
live boards through this path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:23 -05:00
8e018a01e9 feat(store): persist world.ruleset and serve it from GET /ruleset
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:19:10 -05:00
ed8d24bb1d style(sidecar): rustfmt-wrap wide signatures/calls
The release workflow's `cargo fmt --check` gate failed on the Protocol 2.0
additions: upsert_guild/upsert_house signatures and their call sites in
main.rs exceeded rustfmt's default width. Reformatted with `cargo fmt` — no
behavioral change. `cargo fmt --check`, `cargo check`, and `cargo test` all
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:41:37 -05:00
fd9c9fd96a feat(protocol2): Town Cryer news-gump integration (§16, Protocol 2.1)
Website news articles now land in the modern Town Cryer News gump
(TownCryerSystem.NewsEntries), separate from the scrolling-crier lines.

Overlay BridgeNews (new): news.add / news.remove insert/remove a
TownCryerNewsEntry directly in the public NewsEntries list (no stock edit),
tracking our own id->entry map so stock uo.com news is left intact. Title,
HTML body, image, and URL are all supported (the stock gumps already branch on
TextDefinition.Number, so string content renders). On add the article title is
also proclaimed via GlobalTownCrierEntryList (announce defaults on; set
announce:false to suppress). Config caps: NewsMaxTitleLength/BodyLength/
External, NewsAnnounceDurationSec.

Sidecar: POST /news (add/replace, id-correlated), DELETE /news/{id}; news table
stores each article as its news.add command; on shard server.hello the sidecar
replays the stored set with announce:false (the shard rebuilds NewsEntries each
boot and does not persist ours, so the website is the source of truth).

Docs: PROTOCOL_2 §16 (design + verified), INTEGRATION.md /news endpoints.

Verified live: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors); booted shard + sidecar and exercised add/replace/
remove/error paths and the reconnect replay end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:27:13 -05:00
b858d526b8 feat(protocol2): house registry board (Part B ph.3)
Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses ->
house.update / house.remove (owner, region, location, decay level, co-owners,
friends, placement price), complementing the existing house.decay transition
feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status.
Stock ServUO has no "for sale" flag, so this is an owner->houses registry;
price is the placement value, not a listing.

Sidecar: houses board table with upsert/delete/all; main routes house.update/
remove into it; GET /houses served from the store.

Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:05:47 -05:00
2ed0b0bd00 feat(protocol2): guild and town-governor world-state streams (Part B ph.1)
Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and
town governors ("mayors"), both outbound diff-board sweeps mirroring the
existing champ board.

Overlay:
- BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update /
  guild.remove (full-state upsert; disband detected via Disbanded), plus a
  real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only
  the load-time factory, so creation is derived sidecar-side from a first-seen
  id, as champs do.)
- BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update
  (governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled.
- BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both.
- BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s).
- BridgeBoot: both wired into [bridge reload|sweepnow|status.

Sidecar:
- store: guilds + governors board tables with upsert/delete/all.
- main: route guild.update/remove and city.update into the boards.
- web: GET /guilds, GET /governors served from the store (snapshot-companion
  rule, so a fresh page or a restarted sidecar hydrates without the shard).

Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints;
PROTOCOL_2.md Part B phase 1 marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:54:11 -05:00
808f6ab68b feat(protocol2): website account provisioning & unlinking (Part A)
Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the
website can create game accounts and unlink them, gated by a shard-wide
signup mode. The existing [link flow is unchanged.

Overlay:
- BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized
  falls back to game), AccountCreateEnabled (mode-following default),
  RequireIpForCreate, name/password caps, and a boot warning when the core
  Accounts.AutoCreateAccounts setting contradicts the mode.
- BridgeAccounts (new): account.create (mode gate, actor required, char-safety
  mirrored from AccountHandler, collision check, per-IP cap via CanCreate/
  LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link,
  account.audit; password never logged or echoed) and account.unlink (Owner
  floor via BridgeAdmin.Protected, clears the tag).
- BridgeAccountLink: in-game [unlink command, emits account.unlinked.
- BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse.

Sidecar:
- POST /accounts/create, DELETE /link/:account, respond_account status mapping
  (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400).
- store.record_unlink drops the mirrored link row.
- PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2).

Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429;
PROTOCOL_2.md Part A marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:42:06 -05:00
b258ee3e60 feat(champ): stream champion-spawn state to the sidecar board
Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.

Covers all three families via a `category` field:
  - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
  - mini:     MiniChamp (type/level; auto-restarts, no kill counter)
  - sea:      BaseSeaChampion (a High Seas world-boss mobile, alive only
              while summoned; removed via champ.remove when slain)

Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.

Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 05:46:21 -05:00
359bb937b2 style(sidecar): apply rustfmt
Format the sidecar source with `cargo fmt` so the new `cargo fmt --check`
CI gate passes on the first run. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00
c0c01a38d6 Sidecar: auth always-on, protocol version, rich health
Auth is now impossible to turn off by accident. A blank auth_token is never
allowed even on loopback: config load generates a token, writes it back into
sidecar.toml (preserving the rest of the file), logs it, and continues -- so a
forgotten or cleared token self-heals into a working, authenticated setup instead
of silently disabling auth.

  No auth token configured.
  Generated new token: cb99...
  Saved to sidecar.toml. Authentication is on.

Protocol versioning (PROTOCOL_VERSION = 1) lets the website and sidecar detect a
mismatch immediately when a message shape changes. Every response carries an
X-UOLink-Version header; /health and ws.hello include "protocol"; a request that
declares a different X-UOLink-Version is rejected 409 with both versions so the
mismatch is unambiguous. Bump the constant when a contract changes.

/health is now a real troubleshooting panel: status (ok/degraded), protocol,
plugin_connected (is the shard link up), database (SELECT 1), uptime, and
last_event (the timestamp of the last line from the shard). Unauthenticated so
monitoring can reach it.

Verified: a blank token generates + persists + enforces (401 without, 200 with);
X-UOLink-Version header on every response; 409 on a declared mismatch; /health
reports degraded/plugin_connected:false with no shard, then flips to ok/true and a
populated last_event once the shard connects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:09:35 -05:00
946ba7027b Sidecar: SQLite persistence
store.rs (sqlx/sqlite) makes the data durable and queryable over time. Three
tables: events (the full live stream, append-only), links (account <-> website
user, mirrored from link.ok), profiles (last-known character sheet, cached from
char.profile). The event loop persists every live event before broadcasting it;
pong is dropped as ephemeral chatter.

New read endpoints served from the DB rather than the shard: GET /history
(optionally ?kind=), GET /economy (the money-supply series), GET /link/{account}.
GET /char/serial/{serial} now falls back to the cached profile when the shard is
unreachable, so an already-viewed character still renders during an outage;
link.confirm mirrors a successful link into the store.

Verified end to end: 10 economy.supply snapshots and the rest of the live stream
persisted and served via /history and /economy; the data survived a sidecar
restart (14 events still present, and the shard reconnected to the new sidecar);
and with the shard killed, a cached profile returned at HTTP 200 while an uncached
query failed cleanly at 503.

The sidecar is feature-complete: shard link, WebSocket feed, REST queries, and
persistence all work end-to-end against the live shard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:20:33 -05:00