Merge pull request 'docs: move docs to RunicGateway/docs, repoint all references' (#10) from chore/extract-docs into main
All checks were successful
Release sidecar / release (push) Successful in 17s
All checks were successful
Release sidecar / release (push) Successful in 17s
Reviewed-on: #10
This commit is contained in:
28
README.md
28
README.md
@@ -9,6 +9,10 @@ ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidec
|
|||||||
|
|
||||||
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is touched only by a dedicated writer thread draining a bounded queue.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
All project documentation now lives in the central **[RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs)** repo, under [`link/`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link) (design docs, integration guide, protocol spec, research — with full history preserved). Individual docs are linked inline below and referenced throughout the source.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
@@ -17,10 +21,10 @@ The shard never speaks WebSocket. Every world read happens on the Core thread; t
|
|||||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
||||||
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
| `sidecar/` | The Rust sidecar: terminates the loopback link to the shard, exposes WS + REST to the website. See `sidecar/README.md`. |
|
||||||
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
|
| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
|
||||||
| `docs/INTEGRATION.md` | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
|
| [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) | **Website integration guide** — the WebSocket feed, REST endpoints, auth, event catalog, and examples. Start here to build the front end. |
|
||||||
| `docs/PLAN.md` | Implementation plan, measured performance budget, and the full data catalog. |
|
| [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. |
|
||||||
| `docs/RESEARCH.md` | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
|
| [RESEARCH.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/RESEARCH.md) | Original source-level research. Partly superseded — see the corrections table in `PLAN.md` §8. |
|
||||||
| `docs/SHARD_PREREQS.md` | Repairs the target shard needed before any of this could load. |
|
| [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) | Repairs the target shard needed before any of this could load. |
|
||||||
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
||||||
|
|
||||||
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
||||||
@@ -37,13 +41,13 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
|
|||||||
| Phase | State |
|
| Phase | State |
|
||||||
|------:|-------|
|
|------:|-------|
|
||||||
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
||||||
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
|
| 1 — transport (`BridgeLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §11** |
|
||||||
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
|
| 2 — event streams (`BridgeEvents`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §12** |
|
||||||
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
|
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §13** |
|
||||||
| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
|
| 4 — request/response (`BridgeRequests`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §14** |
|
||||||
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** |
|
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §15** |
|
||||||
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** |
|
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §16** |
|
||||||
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in `docs/PLAN.md` §17** |
|
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §17** |
|
||||||
|
|
||||||
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
|
Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar.
|
||||||
|
|
||||||
@@ -91,4 +95,4 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
|
|||||||
|
|
||||||
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
|
||||||
|
|
||||||
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
|
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. See `tools/scaffolding/README.md`.
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
# Administrative Controls — Research & Integration Plan
|
|
||||||
|
|
||||||
**Status:** Research + design. No code written yet.
|
|
||||||
**Date:** 2026-07-12
|
|
||||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
|
||||||
**Companion to** [`PLAN.md`](PLAN.md) (the read/event plane) and [`INTEGRATION.md`](INTEGRATION.md) (the website API). This document covers the **write plane**: staff actions the website should be able to take against the live shard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The question
|
|
||||||
|
|
||||||
The bridge today is almost entirely *outbound*. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs:
|
|
||||||
|
|
||||||
| Verb | File | What it does |
|
|
||||||
|------|------|--------------|
|
|
||||||
| `ping` | `BridgeBoot.cs:139` | Liveness echo. |
|
|
||||||
| `link.confirm` | `BridgeAccountLink.cs` | Ties a game account to a website user. |
|
|
||||||
| `towncrier.add` / `towncrier.remove` | `BridgeTownCrier.cs` | Publishes news to the in-game criers. |
|
|
||||||
|
|
||||||
None of these are *moderation*. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it.
|
|
||||||
|
|
||||||
**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. How ServUO admin controls actually work
|
|
||||||
|
|
||||||
Four mechanisms, all of which the bridge must respect or reuse.
|
|
||||||
|
|
||||||
### 2.1 The AccessLevel ladder
|
|
||||||
|
|
||||||
`Server/Mobile.cs:431`:
|
|
||||||
|
|
||||||
```
|
|
||||||
Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner
|
|
||||||
```
|
|
||||||
|
|
||||||
Every command is gated on a minimum level (`CommandSystem.Register(name, level, handler)`). This ladder is the shard's whole authorization model. **The bridge has no Mobile and therefore no natural place on this ladder** — see §5, the attribution problem.
|
|
||||||
|
|
||||||
### 2.2 The command system
|
|
||||||
|
|
||||||
Two registration styles:
|
|
||||||
|
|
||||||
- **Simple commands** — `CommandSystem.Register("Save", AccessLevel.Administrator, handler)`. The bridge already uses this for `[bridge` (`BridgeBoot.cs:44`, Administrator-gated).
|
|
||||||
- **Generic/target commands** — `BaseCommand` subclasses in `Commands/Generic/Commands/Commands.cs`, registered as objects (`KillCommand`, `KickCommand`, `FirewallCommand`, …). These are built to be *targeted* in-game (click a mobile). Their **logic** is reusable from the bridge; their **targeting/gump plumbing** is not.
|
|
||||||
|
|
||||||
### 2.3 Command logging (the existing audit trail)
|
|
||||||
|
|
||||||
Staff actions call `CommandLogging.WriteLine(from, ...)`, which writes `Logs/Commands/*.log` **and** is the source of the bridge's own `audit.command` / `audit.set` events (`INTEGRATION.md` §4). Any web-initiated action **must** feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised.
|
|
||||||
|
|
||||||
### 2.4 Account model (the moderation state)
|
|
||||||
|
|
||||||
`Scripts/Accounting/Account.cs`. The durable, offline-capable levers live here:
|
|
||||||
|
|
||||||
| Lever | API | Notes |
|
|
||||||
|-------|-----|-------|
|
|
||||||
| Ban (indefinite) | `acct.Banned = true; acct.SetUnspecifiedBan(from)` | `Account.cs:440`, `:1098` |
|
|
||||||
| Ban (timed) | `acct.SetBanTags(from, DateTime.UtcNow, TimeSpan)` then `acct.Banned = true` | `:1103`; `Banned` getter auto-clears when the window lapses (`:454`) |
|
|
||||||
| Unban | `acct.Banned = false; acct.SetUnspecifiedBan(null)` | clears the tags |
|
|
||||||
| Read ban | `acct.GetBanTags(out when, out dur)` | `:1133` |
|
|
||||||
| Staff level | `acct.AccessLevel = …` | `:557` — promotes/demotes a whole account |
|
|
||||||
| Young status | `acct.Young` | `:471` |
|
|
||||||
|
|
||||||
Account-level state persists and applies whether or not the player is online. Per-*mobile* state (below) generally requires the target resident.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Candidate controls
|
|
||||||
|
|
||||||
Grouped by subsystem. **Tier**: **A** = wire in first, **B** = second wave, **N** = never expose remotely. **~~H~~ = excluded.** The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and **cut from the roadmap entirely** per the 2026-07-12 decision — their rows are kept below for the record but will **not** be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph.
|
|
||||||
|
|
||||||
### 3.1 Session control (target online)
|
|
||||||
|
|
||||||
| Control | In-game | Bridge API | Tier | Notes |
|
|
||||||
|---------|---------|-----------|------|-------|
|
|
||||||
| **Kick** | `[Kick` → `KickCommand`, `Commands.cs:1170` | `targ.NetState?.Dispose()` | **A** | Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action. |
|
|
||||||
| **Firewall (IP block)** | `[Firewall`, `Commands.cs:1125` | `Firewall.Add(state.Address)` | **H** | Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp. |
|
|
||||||
| **Locate / who** | `[Where`, `[Client` | already have `char.vitals`/`mob.login` | — | Effectively already covered by the event plane. |
|
|
||||||
|
|
||||||
### 3.2 Account moderation (works offline)
|
|
||||||
|
|
||||||
| Control | In-game | Bridge API | Tier | Notes |
|
|
||||||
|---------|---------|-----------|------|-------|
|
|
||||||
| **Ban (indefinite)** | `[Ban` → `KickCommand(ban:true)`, `Commands.cs:1225` | `Banned=true; SetUnspecifiedBan` + kick live sessions | **A** | The headline verb. Note the in-game path *also* opens `BanDurationGump` — we replace that with an explicit duration in the request. |
|
|
||||||
| **Ban (timed)** | (gump) | `SetBanTags(actor, now, dur); Banned=true` | **A** | Duration in the request body; auto-expires. |
|
|
||||||
| **Unban** | property edit | `Banned=false; SetUnspecifiedBan(null)` | **A** | |
|
|
||||||
| **Mute / squelch** | property `Squelched` | `mob.Squelched = true` (`Mobile.cs:5807`) | **B** | Per-**character**, not per-account. **Persists** across relog + restart (serialized, `Mobile.cs:6489`/`:6013`); works on offline chars too. Mute an account = squelch each resident character (§7.1). |
|
|
||||||
| **Page-mute** | `PagingSquelched` | set on `PlayerMobile` | **B** | Stops help-page spam without a full mute. |
|
|
||||||
| **Set access level** | property `AccessLevel` | `acct.AccessLevel = …` | **H** | Promoting staff from a web UI is a serious privilege path. Gate hard, or omit. |
|
|
||||||
| **Comments / notes** | account comments | `acct.Comments` | **B** | A staff notes field — pairs naturally with a web moderation panel. |
|
|
||||||
|
|
||||||
### 3.3 Player actions (target online)
|
|
||||||
|
|
||||||
| Control | In-game | Bridge API | Tier | Notes |
|
|
||||||
|---------|---------|-----------|------|-------|
|
|
||||||
| **Kill / Resurrect** | `[Kill` / `[Res`, `Commands.cs:966` | `mob.Kill()` / `mob.Resurrect()` | **H** | Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks. |
|
|
||||||
| **Teleport / Bring** | `[Go`, `[Move`, `[Tele` | `mob.MoveToWorld(p, map)` | **B** | "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does. |
|
|
||||||
| **Jail** | region only — `Regions/Jail.cs`, **no stock command** | custom: move to jail point (+ flag) | **H** | Needs us to *build* the action (pick a jail location, decide on release). Region exists; the verb does not. |
|
|
||||||
| **Hide / Unhide** | `[Hide`, `Commands.cs:1066` | `mob.Hidden = bool` | **N** | No remote use case. |
|
|
||||||
| **Set/Get property** | `[Set` / `[Get` / `[Props` | reflection | **N** | Arbitrary property writes = arbitrary power. Keep in-client. |
|
|
||||||
| **Give item / gold** | `[Add`, `Bank` | construct + place | **H** | Compensation flows are real but this is a duplication/economy risk; if wanted, expose *specific* curated grants, never `[add` by type. |
|
|
||||||
|
|
||||||
### 3.4 Support: the help-page queue ★
|
|
||||||
|
|
||||||
`Scripts/Services/Help/PageQueue.cs`. When a player uses the in-game Help button they create a `PageEntry` (`Bug`, `Stuck`, `Account`, `Question`, `Suggestion`, `Harassment`, …) carrying **sender, message, type, location/map, timestamp, and assigned handler**. `PageQueue.List` is the live queue; `PageQueue.Enqueue/Remove` mutate it; a staff reply reaches the player via `ResponseEntry` → `MessageSentGump`.
|
|
||||||
|
|
||||||
This is the single **best** tie-in and deserves its own slice of work:
|
|
||||||
|
|
||||||
- **Stream** new pages as a `page.new` event and removals as `page.closed`.
|
|
||||||
- **Snapshot** the open queue over REST (`GET /pages`).
|
|
||||||
- **Respond** from the website (`POST /pages/{id}/respond`) → delivers a message to the player in-game, exactly like a staff member typing a response.
|
|
||||||
- **Close / assign** a page.
|
|
||||||
|
|
||||||
It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier **A**, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb.
|
|
||||||
|
|
||||||
### 3.5 Broadcast & messaging
|
|
||||||
|
|
||||||
| Control | In-game | Bridge API | Tier | Notes |
|
|
||||||
|---------|---------|-----------|------|-------|
|
|
||||||
| **Server broadcast** | `[BCast`, `Handlers.cs` | `World.Broadcast(hue, ascii, text)` | **A** | Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value. |
|
|
||||||
| **Staff message (SMsg)** | `[SMsg`, `Handlers.cs` | send to online staff | **B** | "Post to staff channel" from the site. |
|
|
||||||
| **Tell / private msg** | `[Tell` | `mob.SendMessage` | **B** | Message one player from the web (e.g. auto-reply to a page). |
|
|
||||||
|
|
||||||
### 3.6 World / server operations
|
|
||||||
|
|
||||||
| Control | In-game | Bridge API | Tier | Notes |
|
|
||||||
|---------|---------|-----------|------|-------|
|
|
||||||
| **Save** | `[Save`, `Handlers.cs` (Administrator) | `AutoSave.Save()` | **B** | Trigger a world save from a deploy/admin panel. Emits `world.save.*` we already stream. |
|
|
||||||
| **Background save** | `[BGSave` | | **B** | Non-blocking variant. |
|
|
||||||
| **Shutdown / restart** | console | process-level | **N** | Do this at the process/host layer, not through a game plugin. |
|
|
||||||
| **Freeze / Wipe / DecorateDelete / TelGen** | various | — | **N** | Destructive world-building. In-client only. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Roadmap (decided)
|
|
||||||
|
|
||||||
> **Build status (2026-07-13):** Phase 1 is **built and live-verified end-to-end**, branch `feature/admin-controls`.
|
|
||||||
> - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:<actor>` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO.
|
|
||||||
> - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token.
|
|
||||||
> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event.
|
|
||||||
> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored.
|
|
||||||
>
|
|
||||||
> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page.
|
|
||||||
>
|
|
||||||
> **Phase 1 + bidirectional audit + Phase 2 are complete — this is the shipped scope.** Phase 3 (below) is **not planned** (owner decision, 2026-07-13). Remaining work is downstream and website-side only: the admin/mod UI (moderation log + support-queue view).
|
|
||||||
|
|
||||||
**Wire in, in order:**
|
|
||||||
|
|
||||||
1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe.
|
|
||||||
2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb.
|
|
||||||
3. ~~**Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save.~~ **Not planned** (owner decision, 2026-07-13). The Tier-B candidates catalogued in §3 stay documented for the record, but the shipped scope is Phase 1 + Phase 2.
|
|
||||||
|
|
||||||
Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5).
|
|
||||||
|
|
||||||
**Excluded — will not be built:** firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary `[set`/`[get`, `[add`, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Authorization & attribution (decided)
|
|
||||||
|
|
||||||
Every in-game moderation command carries a `Mobile from` — the staff member — used for two things the bridge has no natural source for:
|
|
||||||
|
|
||||||
1. **Audit** — `CommandLogging.WriteLine(from, …)` and the `SetBanTags(from, …)` "BanDealer" tag record *who did it*.
|
|
||||||
2. **Authorization** — e.g. `KickCommand` refuses unless `from.AccessLevel > targ.AccessLevel` (`Commands.cs:1200`), so a GM can't ban an Admin.
|
|
||||||
|
|
||||||
The resolved model:
|
|
||||||
|
|
||||||
**Authorization lives on the website.** The website gates these commands behind its own **admin-only** roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary.
|
|
||||||
|
|
||||||
**Sidecar commands carry `CoOwner`-level authority on the shard.** Because the website has already authenticated and authorized the staff user, an inbound `admin.*` is applied as if issued by a synthetic `CoOwner` — the second-highest rung (`Server/Mobile.cs:431`: only `Owner` is above it). This cleanly satisfies the `from.AccessLevel > targ.AccessLevel` guard for every ordinary target.
|
|
||||||
|
|
||||||
**The one shard-side floor: never touch the Owner.** Even at CoOwner authority, an `admin.*` command **refuses any target account whose `AccessLevel >= CoOwner`.** That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). *Note the consequence, plainly:* this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in `Bridge.cfg` (`AdminAccessFloor`).
|
|
||||||
|
|
||||||
**Attribution is an explicit `web:<actor>` string.** Every `admin.*` request carries a required `actor` field — the website username/id of the staff member. The shard:
|
|
||||||
- logs it to the **server console** as `[Bridge][admin] web:<actor> <action> …`. *(Note, corrected during implementation: `CommandLogging.WriteLine` cannot be reused for web actions — it dereferences `from.NetState`/`from.Account`/`from.AccessLevel` (`Scripts/Commands/Logging.cs:93-103`) and there is no staff `Mobile`. So web actions do **not** land in `Logs/Commands/`; the console line plus the `admin.audit` stream plus the website's own log are their durable record. `Logs/Commands/` remains the record for **in-game** staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)*
|
|
||||||
- stores `web:<actor>` in the ban "BanDealer" tag (`SetBanTags` wants a `Mobile from`; we pass `null` for the Mobile and set the tag ourselves — no core edit),
|
|
||||||
- echoes it back in an `admin.audit` event (§5.5) so the website's own moderation record and the game's audit agree.
|
|
||||||
|
|
||||||
**The website keeps its own durable record.** Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's `CommandLogging` + `admin.audit` are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions.
|
|
||||||
|
|
||||||
### 5.5 Bidirectional audit — one moderation picture, both origins
|
|
||||||
|
|
||||||
The website must see moderation actions **whether they originate on the site or in the game client**, in one consistent schema. Two directions:
|
|
||||||
|
|
||||||
- **Web → game (already in the request path).** Each applied `admin.*` emits an unsolicited `admin.audit` broadcast frame to every connected dashboard, tagged `"origin":"web"`, `"actor":"web:<user>"`.
|
|
||||||
- **Game → web (the "full picture" requirement).** When a staff member runs one of these same verbs *in the game client* — `[ban`, `[kick`, `[bcast`, a page-queue response, a mute — the plugin forwards it to the website as the **same** `admin.audit` shape, tagged `"origin":"in-game"`, `"actor":"<staff account/name>"`.
|
|
||||||
|
|
||||||
The raw hook already exists: `BridgeEvents.OnStaffCommand` subscribes to `EventSink.Command` and emits `audit.command` for every staff command (`BridgeEvents.cs:404`), and `OnStaffPropertySet` emits `audit.set`. Those stay as the low-level firehose. On top of them we add a **normalizer** that emits a structured `admin.audit` for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "admin.audit", "origin": "in-game", "action": "ban",
|
|
||||||
"actor": "GreyBeard", "target": "griefer42", "reason": null,
|
|
||||||
"durationSec": 604800, "t": 1783720195626 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**The dispatch path — traced and settled (no longer an open question).** `[ban` and `[kick` *are* registered directly in the command table: `SingleCommandImplementor.Register` calls `CommandSystem.Register(name, level, Redirect)` for each command name (`SingleCommandImplementor.cs:22`), so they sit in `m_Entries` and `EventSink.InvokeCommand(e)` fires for them (`Server/Commands.cs:259`). **So the existing `audit.command` hook already sees them** — the earlier worry that generic commands bypass `EventSink.Command` is wrong.
|
|
||||||
|
|
||||||
The genuine subtlety is *when* it fires and *with what*:
|
|
||||||
|
|
||||||
| Verb shape | Example | What `EventSink.Command` carries | Complete? |
|
|
||||||
|------------|---------|----------------------------------|-----------|
|
|
||||||
| Arg-bearing, no target | `[bcast Server down in 5` | verb **+ full args** | ✅ fully captured |
|
|
||||||
| **Target-cursor** | `[ban` → click victim | verb only, **empty args** | ⚠️ **verb but not the victim** |
|
|
||||||
|
|
||||||
For target-cursor verbs, `Handle` runs `entry.Handler(e)` (→ `Redirect` → `Process` → `from.BeginTarget(...)`, which arms the cursor and returns) and *then* `InvokeCommand(e)` (`Commands.cs:255-259`). The event therefore fires the moment `[ban` is **typed**, before the staff clicks anyone. The resolved action — the actual target and `Account.Banned = true` — happens later inside `KickCommand.Execute`, which calls `CommandLogging.WriteLine(from, "… banning {target}")` **with** the victim (`Commands.cs:1211`).
|
|
||||||
|
|
||||||
**Conclusion:** the reliable choke point for a *resolved* in-game moderation action (verb **and** victim) is `CommandLogging.WriteLine` (`Scripts/Commands/Logging.cs:86`), which is where every command already records its outcome — but it has **no event to subscribe to** today. So the "full picture" needs one small hook:
|
|
||||||
|
|
||||||
- **Add a `WriteLine` event to `Scripts/Commands/Logging.cs`** (a 1-line `Action<Mobile,string>` raised in `WriteLine`). This is a stock file, so it ships as a **`patches/` diff** — the same mechanism Phase 7's `PlayerVendorSale` already established, and arguably the *correct* universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits `admin.audit`.
|
|
||||||
- Broadcasts and other arg-bearing simple commands need **no** patch — the existing `EventSink.Command` hook already carries their full payload; the normalizer just reshapes them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Protocol design
|
|
||||||
|
|
||||||
Reuse the existing inbound machinery verbatim — `BridgeBoot.RegisterHandler(kind, handler)`, Core-thread dispatch via `Timer.DelayCall`, `reqId` echo, and `*.ok` / `*.error` replies — exactly as `BridgeRequests` and `BridgeTownCrier` already do. A new `BridgeAdmin.cs` registers the `admin.*` handlers.
|
|
||||||
|
|
||||||
### Request shape (website → sidecar → shard)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
|
|
||||||
"account": "griefer42", "durationSec": 604800, "reason": "harassment" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `reqId` — correlation id, echoed on the reply (as in `BridgeRequests`).
|
|
||||||
- `actor` — **required.** The website staff user. Rejected if absent.
|
|
||||||
- Target — `account` (offline-capable verbs) or `serial` (online mobiles), resolved with the same `ResolveSerial` / `Accounts.GetAccount` helpers `BridgeRequests` uses.
|
|
||||||
- `reason` — recorded in the audit trail.
|
|
||||||
|
|
||||||
### Reply shape (shard → sidecar → website)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" }
|
|
||||||
{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Map to REST like the rest of `INTEGRATION.md`: `admin.ok` → 200, unknown target → 404, floor-violation/`actor` missing → 403, malformed → 400.
|
|
||||||
|
|
||||||
### Audit event (shard → website, unsolicited)
|
|
||||||
|
|
||||||
Every applied `admin.*` also emits a broadcast audit frame so *all* connected dashboards see it, not just the caller — parallel to the existing `audit.command`, and (per §5.5) emitted for **in-game** uses of the same verbs too:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech",
|
|
||||||
"target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 }
|
|
||||||
```
|
|
||||||
|
|
||||||
`origin` is `"web"` for sidecar-initiated actions or `"in-game"` for actions a staff member took in the game client.
|
|
||||||
|
|
||||||
### Verbs for Phase 1
|
|
||||||
|
|
||||||
| kind | target | required fields | shard action |
|
|
||||||
|------|--------|-----------------|--------------|
|
|
||||||
| `admin.kick` | `serial` or `account` | `actor` | dispose live NetState(s) |
|
|
||||||
| `admin.ban` | `account` | `actor` (+ `durationSec` optional) | set ban tags/flag, then kick live sessions |
|
|
||||||
| `admin.unban` | `account` | `actor` | clear ban |
|
|
||||||
| `admin.broadcast` | — | `actor`, `text` (+ `hue`) | `World.Broadcast` |
|
|
||||||
|
|
||||||
Every one: enforce the **Owner floor** on the target (refuse `AccessLevel >= CoOwner`), apply on the Core thread as a synthetic CoOwner, `CommandLogging.WriteLine("web:<actor> …")`, emit `admin.audit` (`origin:"web"`), reply `admin.ok`/`admin.error`.
|
|
||||||
|
|
||||||
### Caps / defense-in-depth (mirroring town-crier)
|
|
||||||
|
|
||||||
- `actor` required and non-empty.
|
|
||||||
- Target floor: refuse any target with `AccessLevel >= CoOwner` (`AdminAccessFloor` in `Bridge.cfg`, default `CoOwner` → only the Owner/CoOwners are shielded).
|
|
||||||
- `reason` length cap; `durationSec` clamp (min/max); `broadcast` text length cap.
|
|
||||||
- Master switch `AdminWriteEnabled` in `Bridge.cfg` (default **off**) so the whole write plane is opt-in per shard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Verification log (all resolved)
|
|
||||||
|
|
||||||
All resolved by source inspection (ServUO checkout at `C:\Users\colby\Desktop\servuo`). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak.
|
|
||||||
|
|
||||||
1. **`Mobile.Squelched` persists — confirmed durable.** Serialized unconditionally (`Server/Mobile.cs:6489` write) and read back in the version ladder at case 9 (`:6013`), so it survives relog **and** a full server restart; no need to persist it ourselves. It gates `OnSaid` (`:7591` → *"You can not say anything, you have been muted."*). Two consequences for the plan: (a) it is **per-Mobile (per-character), not per-account** — "mute the account" means squelch each resident character; (b) it works on **offline** characters too, since logged-off mobiles stay resident in `World`. Phase 3 mute is therefore durable and offline-capable out of the box.
|
|
||||||
2. **Kicking all sessions — settled.** Enumerate `NetState.Instances` (`Server/Network/NetState.cs:583`, a `ReadOnlyCollection<NetState>`), filter on `ns.Account == acct` (`:574`), and `Dispose()` each. This is **strictly better than walking the account's characters' `NetState`**: a client sitting at character-select has a `NetState` with an `Account` but *no* mobile, and only the `Instances` sweep catches it. `admin.kick` and the live-session cleanup in `admin.ban` both use this.
|
|
||||||
3. **In-game capture of resolved bans/kicks (was the ★ risk).** Traced through the dispatch path — settled in §5.5. `[ban`/`[kick` *do* raise `EventSink.Command`, but at type-time without the target. The complete capture point is a **1-line event added to `Scripts/Commands/Logging.cs:86`**, shipped as a `patches/` diff. Broadcasts need no patch.
|
|
||||||
4. **Ban attribution** — pass `null` for the `Mobile from` and set `web:<actor>` as the `BanDealer` tag ourselves. No core edit.
|
|
||||||
5. **Broadcast + town-crier** — keep both; they differ (instant system line vs. looping crier) and both are cheap.
|
|
||||||
6. **Access floor** — `CoOwner` (Owner-only shield). See §5.
|
|
||||||
|
|
||||||
**Nothing in §7 remains open — the plan is implementation-ready.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Decisions — locked 2026-07-12
|
|
||||||
|
|
||||||
- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later.
|
|
||||||
- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`.
|
|
||||||
- **Attribution:** `web:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
|
|
||||||
- **Logging:** the **website keeps its own durable moderation record**; the plugin **forwards in-game uses** of these same verbs to the site as `admin.audit` (`origin:"in-game"`) so the picture is complete from both sides (§5.5).
|
|
||||||
- **Help-page queue:** confirmed, lands as **Phase 2**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeAdmin.cs` | New. Registers `admin.*` handlers; the CoOwner-authority application + Owner floor; `web` `admin.audit` emission. Mirrors `BridgeTownCrier.cs` structure. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeEvents.cs` | Extend: normalize in-game moderation verbs into `admin.audit` (`origin:"in-game"`). Broadcasts reshape from the existing `EventSink.Command` hook; ban/kick subscribe to the new `CommandLogging` event (§5.5). |
|
|
||||||
| `patches/commandlogging-event.patch` | New. Adds a 1-line `Action<Mobile,string>` event to `Scripts/Commands/Logging.cs:86` so resolved staff actions (verb **+ target**) are observable. Stock file → ships as a patch, per the Phase-7 precedent. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgePages.cs` | New (Phase 2). Streams/snapshots/answers the `PageQueue`. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | Add `AdminWriteEnabled` (default off), `AdminAccessFloor` (default `CoOwner`), and the caps. |
|
|
||||||
| `sidecar/src/web.rs` | New REST routes (`POST /admin/*`, `/pages/*`) → inbound lines; map replies to status codes. |
|
|
||||||
| `docs/INTEGRATION.md` | Document the new endpoints + the `admin.audit` / `page.*` events. |
|
|
||||||
| *(website, separate repo)* | Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound `admin.audit` frames. |
|
|
||||||
|
|
||||||
The Phase-1 **verbs** need no core or stock edit — every web-initiated action is an existing script-layer API called from the new `BridgeAdmin.cs` overlay. The only non-overlay change is the **one-line `CommandLogging` event** (`patches/commandlogging-event.patch`), needed solely so *in-game* bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 `patches/` mechanism and touches nothing else.
|
|
||||||
@@ -1,712 +0,0 @@
|
|||||||
# uo-link Sidecar — Website Integration Guide
|
|
||||||
|
|
||||||
This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.
|
|
||||||
|
|
||||||
```
|
|
||||||
website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS.
|
|
||||||
- **Content type** — all request and response bodies are JSON (`application/json`).
|
|
||||||
- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC.
|
|
||||||
- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Authentication
|
|
||||||
|
|
||||||
Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways:
|
|
||||||
|
|
||||||
| Transport | How |
|
|
||||||
|-----------|-----|
|
|
||||||
| REST | `Authorization: Bearer <token>` |
|
|
||||||
| REST | `X-Api-Key: <token>` |
|
|
||||||
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
|
|
||||||
|
|
||||||
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Protocol version
|
|
||||||
|
|
||||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
|
||||||
|
|
||||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
|
||||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
|
||||||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
|
|
||||||
```
|
|
||||||
|
|
||||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
|
||||||
|
|
||||||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Health
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /health (no auth)
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
|
||||||
"protocol": 1,
|
|
||||||
"plugin_connected": true, // is the shard link up right now?
|
|
||||||
"database": "ok", // "ok" | "error"
|
|
||||||
"uptime": "3d 12h",
|
|
||||||
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. WebSocket live feed
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /ws?token=<token> (WebSocket upgrade)
|
|
||||||
```
|
|
||||||
|
|
||||||
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
|
|
||||||
|
|
||||||
**On connect**, the first frame is:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "ws.hello", "protocol": 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
|
|
||||||
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
|
|
||||||
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
|
|
||||||
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
|
|
||||||
|
|
||||||
### Minimal browser client
|
|
||||||
|
|
||||||
```js
|
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
|
|
||||||
ws.onmessage = (m) => {
|
|
||||||
const ev = JSON.parse(m.data);
|
|
||||||
switch (ev.kind) {
|
|
||||||
case "ws.hello": /* check ev.protocol === 1 */ break;
|
|
||||||
case "mob.login": onLogin(ev); break;
|
|
||||||
case "vendor.sale": onSale(ev); break;
|
|
||||||
case "house.decay": onIdoc(ev); break;
|
|
||||||
// ...handle the kinds you care about; ignore the rest
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event catalog
|
|
||||||
|
|
||||||
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
|
|
||||||
|
|
||||||
#### Lifecycle
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
|
|
||||||
| `server.shutdown` | — | Clean shutdown. |
|
|
||||||
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
|
|
||||||
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
|
|
||||||
|
|
||||||
#### Sessions & identity
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
|
||||||
| `mob.logout` | `who` |
|
|
||||||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
|
|
||||||
|
|
||||||
#### Economy & commerce
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
|
|
||||||
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
|
|
||||||
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
|
|
||||||
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
|
|
||||||
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
|
|
||||||
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
|
|
||||||
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
|
|
||||||
"price":100,"commission":0,"t":1783720195626}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Character progression & vitals
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
|
|
||||||
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
|
|
||||||
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
|
|
||||||
| `quest.complete` | `who`, `quest` | |
|
|
||||||
|
|
||||||
#### Death & PvP
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `player.death` | `who`, `killer` |
|
|
||||||
| `player.murdered` | `victim`, `murderer` |
|
|
||||||
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
|
|
||||||
|
|
||||||
#### Housing / IDOC
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
|
||||||
|
|
||||||
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
|
||||||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Economy supply (periodic)
|
|
||||||
| kind | fields |
|
|
||||||
|------|--------|
|
|
||||||
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
|
|
||||||
|
|
||||||
#### Cheat detection & staff audit
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
|
|
||||||
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
|
|
||||||
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
|
|
||||||
| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:<user>"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. |
|
|
||||||
|
|
||||||
#### Account linking & provisioning
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
|
|
||||||
| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:<user>"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. |
|
|
||||||
| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. |
|
|
||||||
|
|
||||||
#### Help-page (support) queue
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
|
|
||||||
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
|
|
||||||
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
|
|
||||||
|
|
||||||
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
|
||||||
|
|
||||||
#### Champion spawns
|
|
||||||
|
|
||||||
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
|
|
||||||
|
|
||||||
| `category` | source | what it is |
|
|
||||||
|------------|--------|-----------|
|
|
||||||
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
|
|
||||||
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
|
|
||||||
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
|
||||||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
|
||||||
|
|
||||||
`status` is one of:
|
|
||||||
- **`active`** — running (or, for `sea`, the boss is alive).
|
|
||||||
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
|
||||||
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
|
||||||
|
|
||||||
Category-specific fields on `champ.update`:
|
|
||||||
|
|
||||||
| category | extra fields |
|
|
||||||
|----------|--------------|
|
|
||||||
| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
|
|
||||||
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
|
|
||||||
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
|
||||||
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
|
|
||||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
|
|
||||||
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
|
|
||||||
|
|
||||||
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
|
|
||||||
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
|
|
||||||
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
|
||||||
|
|
||||||
#### Guilds (Protocol 2.0)
|
|
||||||
|
|
||||||
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
|
|
||||||
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
|
||||||
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
|
||||||
|
|
||||||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
|
||||||
"online":3,"alliance":"Britannian Pact",
|
|
||||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
"t":1752489280000}
|
|
||||||
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
|
||||||
|
|
||||||
#### Town governors (Protocol 2.0)
|
|
||||||
|
|
||||||
In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.**
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. |
|
|
||||||
|
|
||||||
`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
|
||||||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
"governorElect":null,"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render the current board from `GET /governors` (§6) on connect, then keep it live with these events.
|
|
||||||
|
|
||||||
#### Presence (Protocol 2.0)
|
|
||||||
|
|
||||||
Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time.
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. |
|
|
||||||
| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
|
||||||
"byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000}
|
|
||||||
{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca",
|
|
||||||
"who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Houses (Protocol 2.0)
|
|
||||||
|
|
||||||
The house registry — one row per house, complementing the `house.decay` *transition* feed (§ above). Polled (`HousingSweepSeconds`, default 300s) and diffed like the other boards.
|
|
||||||
|
|
||||||
| kind | fields | notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `house.update` | `serial`, `name`, `owner` (actor or null), `coOwners`, `friends`, `region`, `map`, `x`,`y`,`z`, `decay`, `price`, `builtOn`, `lastRefreshed` | A house's owner/region/decay/co-owners changed, or first sight this connection. `decay` is the level name (e.g. `LikeNew`). `price` is the placement value — **stock ServUO has no "for sale" flag**, so this is not a listing. |
|
|
||||||
| `house.remove` | `serial` | The house was demolished or no longer exists. Drop the row. |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew",
|
|
||||||
"price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
|
|
||||||
"owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
|
||||||
"coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z",
|
|
||||||
"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
Render from `GET /houses` (§6) on connect, then keep live with these events.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. REST — read queries
|
|
||||||
|
|
||||||
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
|
|
||||||
|
|
||||||
### Character profile
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /char/{account}/{slot} # by account + character slot (0-based)
|
|
||||||
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
|
|
||||||
```
|
|
||||||
|
|
||||||
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
|
|
||||||
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
|
|
||||||
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
|
|
||||||
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
|
|
||||||
"fame":0,"karma":0,"luck":0,
|
|
||||||
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
|
|
||||||
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
|
|
||||||
"equipment": [
|
|
||||||
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
|
|
||||||
"cliloc":1027933,"mods":{} },
|
|
||||||
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
|
|
||||||
"weapon":{"minDamage":16,"maxDamage":18},
|
|
||||||
"mods":{"WeaponDamage":50,"HitLightning":40} }
|
|
||||||
],
|
|
||||||
"titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman",
|
|
||||||
"reward": ["1154060", "The Bold"] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Field notes:
|
|
||||||
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
|
|
||||||
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
|
|
||||||
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
|
|
||||||
- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names.
|
|
||||||
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
|
|
||||||
|
|
||||||
### Account roster
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /roster/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
Lightweight list of an account's characters (up to 5–7), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"account.roster", "acct":"whitlocktech",
|
|
||||||
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Player vendors
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /vendors/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every player vendor owned by any character on the account, with held gold and current listings.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"vendor.snapshot", "acct":"seed_000",
|
|
||||||
"vendors":[
|
|
||||||
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
|
|
||||||
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
|
|
||||||
"listings":[
|
|
||||||
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
|
|
||||||
] } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. REST — commands & history
|
|
||||||
|
|
||||||
### Confirm an account link
|
|
||||||
|
|
||||||
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /link/confirm
|
|
||||||
{ "code": "AB12CD", "websiteUserId": "9931" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
|
|
||||||
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
|
|
||||||
|
|
||||||
Codes are one-time and expire (default 5 min).
|
|
||||||
|
|
||||||
### Look up an existing link
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /link/{account}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
|
|
||||||
- **404** `{"account":"PerryAdimn","linked":false}` if not.
|
|
||||||
|
|
||||||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
|
||||||
|
|
||||||
### Create a game account (Protocol 2.0)
|
|
||||||
|
|
||||||
Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses).
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /accounts/create
|
|
||||||
{ "actor": "whitlocktech", "account": "bob", "password": "hunter2",
|
|
||||||
"websiteUserId": "9931", "ip": "203.0.113.7" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `actor` — the website user/staff id, recorded in the audit. Required.
|
|
||||||
- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log.
|
|
||||||
- `websiteUserId` — the site user to auto-link.
|
|
||||||
- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it.
|
|
||||||
|
|
||||||
Responses:
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`.
|
|
||||||
- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`.
|
|
||||||
- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`.
|
|
||||||
- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`.
|
|
||||||
- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`.
|
|
||||||
- Bad username/password, or a missing field → **400**.
|
|
||||||
|
|
||||||
Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility.
|
|
||||||
|
|
||||||
### Unlink an account (Protocol 2.0)
|
|
||||||
|
|
||||||
Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /link/{account}
|
|
||||||
{ "actor": "whitlocktech" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately.
|
|
||||||
- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`.
|
|
||||||
- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`.
|
|
||||||
- Missing `actor` → **400**.
|
|
||||||
|
|
||||||
A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record.
|
|
||||||
|
|
||||||
### Publish / remove town-crier news
|
|
||||||
|
|
||||||
Push a message that every in-game town crier announces until it expires.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /towncrier
|
|
||||||
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /towncrier/{id}
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
|
|
||||||
|
|
||||||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
|
||||||
|
|
||||||
### Publish / remove Town Cryer **news** (Protocol 2.1)
|
|
||||||
|
|
||||||
Distinct from the scrolling-crier lines above: this puts a full article — title, HTML body, image, and a "more info" URL — into the in-game **Town Cryer News gump**, and (by default) has the criers proclaim the **title** in-world.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /news
|
|
||||||
{ "id": "42", "title": "Double XP Weekend",
|
|
||||||
"body": "<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
|
|
||||||
"image": 1614, "url": "https://yoursite/news/42" }
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"news.ok","id":"42"}`. Re-posting the same `id` **replaces** the prior article in place.
|
|
||||||
|
|
||||||
- `id`, `title` required. `body` (HTML supported), `image` (a UO gump id; a neutral scroll if omitted), `url` (a browser button in the gump) optional.
|
|
||||||
- `announce` defaults to **true** — the criers proclaim the title. Send `"announce": false` to post silently (e.g. a correction).
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /news/{id}
|
|
||||||
```
|
|
||||||
→ **200** `{"kind":"news.ok","id":"42"}`, or **404** `{"kind":"news.error","reason":"unknown id"}`.
|
|
||||||
|
|
||||||
Caps apply (title/body length, max active articles). The **website is the source of truth**: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately.
|
|
||||||
|
|
||||||
### Staff moderation — the write plane
|
|
||||||
|
|
||||||
Account and session moderation against the live shard. **These are privileged.** The sidecar does
|
|
||||||
not model per-user roles — **your site must authenticate the staff user and check their permission
|
|
||||||
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
|
|
||||||
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
|
|
||||||
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
|
|
||||||
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
|
|
||||||
|
|
||||||
Every request requires an **`actor`** — the website username/id of the staff member taking the
|
|
||||||
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
|
|
||||||
event, so actions are always attributable. A missing `actor` is **400**.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
|
|
||||||
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
|
|
||||||
POST /admin/unban { "actor":"jane", "account":"griefer42" }
|
|
||||||
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- **kick** — disconnects every live session of the target account (including one parked at
|
|
||||||
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
|
|
||||||
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
|
|
||||||
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
|
|
||||||
`AdminBanMaxDurationSec`.
|
|
||||||
- **unban** — clears the ban.
|
|
||||||
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
|
|
||||||
Length-capped by the shard.
|
|
||||||
|
|
||||||
Success → **200** with an `admin.ok`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Failure → an `admin.error` with a mapped status:
|
|
||||||
|
|
||||||
| Status | When |
|
|
||||||
|--------|------|
|
|
||||||
| 400 | missing `actor`, malformed body, or bad parameter |
|
|
||||||
| 401 | missing/invalid auth token |
|
|
||||||
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
|
|
||||||
| 404 | unknown or accountless target |
|
|
||||||
| 503 / 504 | shard not connected / didn't reply in time |
|
|
||||||
|
|
||||||
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
|
|
||||||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
|
||||||
by staff in the game client surfaces the same way with `origin:"in-game"`.
|
|
||||||
|
|
||||||
### Help-page (support) queue
|
|
||||||
|
|
||||||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
|
||||||
roles, like the moderation endpoints above.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /pages # the open queue, newest state
|
|
||||||
POST /pages/{pageId}/respond { "message":"...", "close": false }
|
|
||||||
POST /pages/{pageId}/close
|
|
||||||
```
|
|
||||||
|
|
||||||
- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
|
|
||||||
event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
|
|
||||||
with the `page.new` / `page.updated` / `page.closed` events.
|
|
||||||
- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
|
|
||||||
they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
|
|
||||||
`"close": true` to resolve the page in the same call. → **200** `page.ok`.
|
|
||||||
- **close** removes the page from the queue. → **200** `page.ok`.
|
|
||||||
- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
|
|
||||||
|
|
||||||
```json
|
|
||||||
POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
|
|
||||||
→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
|
|
||||||
```
|
|
||||||
|
|
||||||
### History (from the sidecar's database)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000)
|
|
||||||
GET /economy?limit={n} # the money-supply series (economy.supply events)
|
|
||||||
```
|
|
||||||
|
|
||||||
Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /history?kind=vendor.sale&limit=50
|
|
||||||
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }
|
|
||||||
|
|
||||||
GET /economy?limit=200
|
|
||||||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Champion-spawn board
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /champs
|
|
||||||
```
|
|
||||||
|
|
||||||
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /champs
|
|
||||||
→ { "spawns": [
|
|
||||||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
|
||||||
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
|
|
||||||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
|
|
||||||
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
|
|
||||||
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
|
|
||||||
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
|
|
||||||
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
|
|
||||||
] }
|
|
||||||
```
|
|
||||||
|
|
||||||
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
|
|
||||||
|
|
||||||
### Guild board (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /guilds
|
|
||||||
→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"members":14,"online":3,"alliance":"Britannian Pact",
|
|
||||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
|
||||||
"t":1752489280000}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
|
|
||||||
|
|
||||||
### Governor board (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /governors
|
|
||||||
→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
|
||||||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
|
||||||
"governorElect":null,"t":1752489280000}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city.
|
|
||||||
|
|
||||||
### Online population (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /online
|
|
||||||
→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
|
||||||
"byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000}
|
|
||||||
```
|
|
||||||
|
|
||||||
The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`.
|
|
||||||
|
|
||||||
### House registry (Protocol 2.0)
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /houses
|
|
||||||
→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil",
|
|
||||||
"decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain",
|
|
||||||
"owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
|
||||||
"coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Status codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| 200 | OK |
|
|
||||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
|
||||||
| 401 | Missing or invalid auth token |
|
|
||||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
|
||||||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
|
||||||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
|
||||||
| 500 | Internal error (e.g. database) |
|
|
||||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
|
||||||
| 504 | Shard connected but didn't reply within 10s |
|
|
||||||
|
|
||||||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Putting it together
|
|
||||||
|
|
||||||
A typical character page:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
|
||||||
|
|
||||||
// 1. render the roster
|
|
||||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
|
||||||
|
|
||||||
// 2. full sheet for the selected character
|
|
||||||
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
|
|
||||||
if (res.status === 503) showBanner("Game server is restarting…");
|
|
||||||
else renderProfile(await res.json());
|
|
||||||
|
|
||||||
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
|
|
||||||
// (see the WebSocket client in §4)
|
|
||||||
|
|
||||||
// 4. recent sales widget
|
|
||||||
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
|
|
||||||
.then(r => r.json());
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Caveats & current limits
|
|
||||||
|
|
||||||
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
|
|
||||||
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
|
|
||||||
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
|
|
||||||
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
|
|
||||||
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.
|
|
||||||
508
docs/PLAN.md
508
docs/PLAN.md
@@ -1,508 +0,0 @@
|
|||||||
# ServUO Bridge Plugin — Implementation Plan & Data Catalog
|
|
||||||
|
|
||||||
**Status:** Design, grounded in **measurements taken on this shard**, not estimates.
|
|
||||||
**Date:** 2026-07-10
|
|
||||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
|
||||||
**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong.
|
|
||||||
|
|
||||||
Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Measured budget
|
|
||||||
|
|
||||||
Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log.
|
|
||||||
|
|
||||||
| Read | Cost | Payload | Per-unit |
|
|
||||||
|------|------|---------|----------|
|
|
||||||
| Full character profile | **0.069 ms/char** | 2,386 B JSON | — |
|
|
||||||
| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char |
|
|
||||||
| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house |
|
|
||||||
| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct |
|
|
||||||
| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing |
|
|
||||||
|
|
||||||
Linear extrapolation at the same gear complexity:
|
|
||||||
|
|
||||||
| Scenario | Cost | Verdict |
|
|
||||||
|----------|------|---------|
|
|
||||||
| Vitals sweep @ 200 online | 0.30 ms | free |
|
|
||||||
| Vitals sweep @ 1000 online | 1.49 ms | free |
|
|
||||||
| Decay sweep @ 2000 houses | 0.38 ms | free |
|
|
||||||
| Economy @ 5000 accounts | 0.06 ms | free |
|
|
||||||
| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** |
|
|
||||||
|
|
||||||
**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes.
|
|
||||||
|
|
||||||
### Caveat on these numbers
|
|
||||||
|
|
||||||
Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars.
|
|
||||||
|
|
||||||
Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture (confirmed, unchanged)
|
|
||||||
|
|
||||||
```
|
|
||||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
|
||||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
|
||||||
```
|
|
||||||
|
|
||||||
ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust.
|
|
||||||
|
|
||||||
Non-negotiable rules, all of which the measurements support:
|
|
||||||
|
|
||||||
- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`.
|
|
||||||
- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it.
|
|
||||||
- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API.
|
|
||||||
- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard.
|
|
||||||
- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Prerequisite: fix the build, or the plugin will not load
|
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences:
|
|
||||||
|
|
||||||
1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.)
|
|
||||||
2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.**
|
|
||||||
|
|
||||||
**Fix before writing any bridge code.** Either add a default `<Platform>x64</Platform>` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Plugin layout
|
|
||||||
|
|
||||||
All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug.
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. |
|
|
||||||
| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. |
|
|
||||||
| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. |
|
|
||||||
| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. |
|
|
||||||
| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. |
|
|
||||||
| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. |
|
|
||||||
| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. |
|
|
||||||
|
|
||||||
**Lifecycle** (`Server/Main.cs:544-562`, all Core thread):
|
|
||||||
`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`.
|
|
||||||
|
|
||||||
Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Data catalog — everything the shard can give you
|
|
||||||
|
|
||||||
91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified.
|
|
||||||
|
|
||||||
### 5.1 Session & identity
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. |
|
|
||||||
| Player offline | `EventSink.Logout` | low | Pair with Login. |
|
|
||||||
| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. |
|
|
||||||
| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. |
|
|
||||||
| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. |
|
|
||||||
| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. |
|
|
||||||
|
|
||||||
### 5.2 Character state
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. |
|
|
||||||
| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. |
|
|
||||||
| Skill progression | `SkillGain` | medium | High-signal. Ship it. |
|
|
||||||
| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. |
|
|
||||||
| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. |
|
|
||||||
| Hunger | `HungerChanged` | low | Cosmetic; optional. |
|
|
||||||
|
|
||||||
> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free.
|
|
||||||
>
|
|
||||||
> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7.
|
|
||||||
|
|
||||||
### 5.3 Economy & commerce
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). |
|
|
||||||
| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. |
|
|
||||||
| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** |
|
|
||||||
| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. |
|
|
||||||
| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. |
|
|
||||||
| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. |
|
|
||||||
| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. |
|
|
||||||
| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. |
|
|
||||||
|
|
||||||
> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.**
|
|
||||||
|
|
||||||
Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold.
|
|
||||||
|
|
||||||
### 5.4 Housing / IDOC
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. |
|
|
||||||
|
|
||||||
Hold a `Dictionary<Serial, DecayLevel>` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot.
|
|
||||||
|
|
||||||
**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected:
|
|
||||||
|
|
||||||
- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`).
|
|
||||||
- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest.
|
|
||||||
- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`.
|
|
||||||
|
|
||||||
Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`.
|
|
||||||
|
|
||||||
### 5.5 Combat, death, PvP
|
|
||||||
|
|
||||||
| Signal | Hook | Freq | Notes |
|
|
||||||
|--------|------|:----:|-------|
|
|
||||||
| Player death | `PlayerDeath` | low | |
|
|
||||||
| Murder | `PlayerMurdered` | low | High-signal for the website. |
|
|
||||||
| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. |
|
|
||||||
| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. |
|
|
||||||
| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. |
|
|
||||||
|
|
||||||
> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink.
|
|
||||||
|
|
||||||
### 5.6 Progression & activity
|
|
||||||
|
|
||||||
`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`.
|
|
||||||
|
|
||||||
`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`.
|
|
||||||
|
|
||||||
> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`.
|
|
||||||
>
|
|
||||||
> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object.
|
|
||||||
|
|
||||||
### 5.7 Cheat detection & staff audit
|
|
||||||
|
|
||||||
This is where the catalog earns its keep, and it is thin in the original doc.
|
|
||||||
|
|
||||||
| Signal | Hook | Why |
|
|
||||||
|--------|------|-----|
|
|
||||||
| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. |
|
|
||||||
| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. |
|
|
||||||
| Staff commands | `EventSink.Command` | Every command invocation. |
|
|
||||||
| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. |
|
|
||||||
| Gold flow | `AccountGoldChange` | Reconcile against sale stream. |
|
|
||||||
|
|
||||||
### 5.8 Lifecycle
|
|
||||||
|
|
||||||
`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`.
|
|
||||||
|
|
||||||
`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.**
|
|
||||||
|
|
||||||
### 5.9 Known gaps (no clean hook)
|
|
||||||
|
|
||||||
- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.**
|
|
||||||
- **Per-hit combat damage.** Virtual overrides only.
|
|
||||||
- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only.
|
|
||||||
- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The one core edit: `PlayerVendorSale`
|
|
||||||
|
|
||||||
Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// PlayerVendorGumps.cs:84-96
|
|
||||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
|
||||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
|
||||||
int commission = 0;
|
|
||||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale.
|
|
||||||
|
|
||||||
Three edits, then the bridge stays pure-subscription:
|
|
||||||
|
|
||||||
1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape).
|
|
||||||
2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96.
|
|
||||||
3. Bridge subscribes in `Initialize` like any other event.
|
|
||||||
|
|
||||||
~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Wire protocol
|
|
||||||
|
|
||||||
Newline-delimited JSON, one object per line, `serial` as the primary key.
|
|
||||||
|
|
||||||
### Outbound (shard → sidecar)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2,
|
|
||||||
"items":206467,"mobiles":42826,"accounts":51}
|
|
||||||
{"t":1752…,"kind":"server.shutdown"}
|
|
||||||
{"t":1752…,"kind":"server.crashed","error":"…"}
|
|
||||||
{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"}
|
|
||||||
{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88,
|
|
||||||
"str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true}
|
|
||||||
{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500}
|
|
||||||
{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"},
|
|
||||||
"owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21",
|
|
||||||
"item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750}
|
|
||||||
{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC",
|
|
||||||
"map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0},
|
|
||||||
"region":"Britain","name":"The Silver Anvil",
|
|
||||||
"owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[],
|
|
||||||
"builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"}
|
|
||||||
{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"}
|
|
||||||
{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1}
|
|
||||||
{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209}
|
|
||||||
```
|
|
||||||
|
|
||||||
`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names.
|
|
||||||
|
|
||||||
### Inbound (sidecar → shard)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"char.request","account":"PerryAdimn","slot":0}
|
|
||||||
{"kind":"account.roster","account":"PerryAdimn"}
|
|
||||||
{"kind":"vendor.snapshot","owner":"PerryAdimn"}
|
|
||||||
{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}
|
|
||||||
{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600}
|
|
||||||
{"kind":"towncrier.remove","id":"n123"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Every inbound handler marshals to the Core thread before touching world state.
|
|
||||||
|
|
||||||
### `server.hello` is per-connection, not per-boot
|
|
||||||
|
|
||||||
The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to.
|
|
||||||
|
|
||||||
`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`.
|
|
||||||
|
|
||||||
Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning.
|
|
||||||
|
|
||||||
### Item names are clilocs
|
|
||||||
|
|
||||||
`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Corrections to `BRIDGE_FINDINGS.md`
|
|
||||||
|
|
||||||
| § | Claim | Reality |
|
|
||||||
|---|-------|---------|
|
|
||||||
| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. |
|
|
||||||
| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. |
|
|
||||||
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
|
|
||||||
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
|
|
||||||
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
|
|
||||||
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
|
|
||||||
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Implementation phases
|
|
||||||
|
|
||||||
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
|
|
||||||
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
|
|
||||||
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
|
|
||||||
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
|
|
||||||
4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
|
|
||||||
5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15.
|
|
||||||
6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16.
|
|
||||||
7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17.
|
|
||||||
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
|
|
||||||
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
|
|
||||||
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
|
|
||||||
8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar.
|
|
||||||
|
|
||||||
### Config keys (`Config/Bridge.cfg`)
|
|
||||||
|
|
||||||
```ini
|
|
||||||
Host=127.0.0.1
|
|
||||||
Port=7788
|
|
||||||
QueueCap=10000
|
|
||||||
StatSweepSeconds=30
|
|
||||||
DecaySweepSeconds=60
|
|
||||||
EconomySweepSeconds=300
|
|
||||||
```
|
|
||||||
|
|
||||||
Read in `Configure()` via `Config.Get<T>("Bridge.<Key>", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Phase 1 acceptance
|
|
||||||
|
|
||||||
Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed.
|
|
||||||
|
|
||||||
| Claim | Evidence |
|
|
||||||
|-------|----------|
|
|
||||||
| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. |
|
|
||||||
| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. |
|
|
||||||
| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. |
|
|
||||||
| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` |
|
|
||||||
| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. |
|
|
||||||
| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. |
|
|
||||||
| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. |
|
|
||||||
|
|
||||||
Two defects were found this way and fixed:
|
|
||||||
|
|
||||||
- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s.
|
|
||||||
- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Phase 7 acceptance
|
|
||||||
|
|
||||||
The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`.
|
|
||||||
|
|
||||||
Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so.
|
|
||||||
|
|
||||||
Verified end to end with a probe that fired the event using **real seeded-vendor data**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"vendor.sale","committed":true,
|
|
||||||
"buyerSerial":"0x1F8","buyerAcct":"seed_001",
|
|
||||||
"ownerSerial":"0x1F5","ownerAcct":"seed_000",
|
|
||||||
"vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword",
|
|
||||||
"itemId":3937,"amount":1,"price":69819,"commission":0}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events.
|
|
||||||
|
|
||||||
**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Phase 6 acceptance
|
|
||||||
|
|
||||||
`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary.
|
|
||||||
|
|
||||||
Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree:
|
|
||||||
|
|
||||||
| Sent | Reply | Crier list |
|
|
||||||
|------|-------|------------|
|
|
||||||
| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines |
|
|
||||||
| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list |
|
|
||||||
| `remove n1` | `towncrier.ok` | entry gone |
|
|
||||||
| `remove does-not-exist` | `towncrier.error "unknown id"` | no change |
|
|
||||||
|
|
||||||
The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged.
|
|
||||||
|
|
||||||
Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs.
|
|
||||||
|
|
||||||
No core changes; this closes the pure-plugin inbound work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Phase 5 acceptance
|
|
||||||
|
|
||||||
`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`.
|
|
||||||
|
|
||||||
Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it):
|
|
||||||
|
|
||||||
```
|
|
||||||
<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300
|
|
||||||
-> link.confirm code=77M9TK websiteUserId=web-9931
|
|
||||||
<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931
|
|
||||||
-> link.confirm code=BADCOD ...
|
|
||||||
<- link.error code=BADCOD reason="unknown or expired code"
|
|
||||||
```
|
|
||||||
|
|
||||||
**The tag persists.** After a `World.Save()`, `accounts.xml` contained:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<tags>
|
|
||||||
<tag name="WebsiteUserId">web-9931</tag>
|
|
||||||
</tags>
|
|
||||||
```
|
|
||||||
|
|
||||||
This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised.
|
|
||||||
|
|
||||||
Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup.
|
|
||||||
|
|
||||||
Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Phase 4 acceptance
|
|
||||||
|
|
||||||
`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
|
|
||||||
|
|
||||||
Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
|
|
||||||
|
|
||||||
- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
|
|
||||||
- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
|
|
||||||
- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
|
|
||||||
- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
|
|
||||||
- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
|
|
||||||
|
|
||||||
Two things the real character surfaced that the seeded dummies could not:
|
|
||||||
|
|
||||||
- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
|
|
||||||
- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
|
|
||||||
|
|
||||||
Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Phase 3 acceptance
|
|
||||||
|
|
||||||
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
|
|
||||||
|
|
||||||
Verified on the seeded world with intervals cut to 8 s:
|
|
||||||
|
|
||||||
- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
|
||||||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
|
||||||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
|
||||||
"builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`.
|
|
||||||
- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client.
|
|
||||||
|
|
||||||
Notes from the run:
|
|
||||||
|
|
||||||
- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses.
|
|
||||||
- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless.
|
|
||||||
- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Phase 2 acceptance
|
|
||||||
|
|
||||||
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
|
|
||||||
|
|
||||||
```
|
|
||||||
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
|
|
||||||
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
|
|
||||||
{"kind":"karma.change",...,"old":7903,"new":7853}
|
|
||||||
{"kind":"world.save.before"}
|
|
||||||
{"kind":"world.save.after","items":206312,"mobiles":42826}
|
|
||||||
```
|
|
||||||
|
|
||||||
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
|
|
||||||
|
|
||||||
### The finding: `SkillGain` fires for NPCs, hard
|
|
||||||
|
|
||||||
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
|
|
||||||
|
|
||||||
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
|
|
||||||
|
|
||||||
### Safety facts baked into the handlers
|
|
||||||
|
|
||||||
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
|
|
||||||
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
|
|
||||||
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
|
|
||||||
- The property is `FastWalkEventArgs.NetState`, not `.State`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Operational notes
|
|
||||||
|
|
||||||
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.
|
|
||||||
- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`.
|
|
||||||
- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
|
||||||
- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user.
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
# Protocol 2.0 — Provisioning & World-State Streams
|
|
||||||
|
|
||||||
**Status:** Parts A + B (phases 1–4) **built and smoke-tested live** on branch `feat/protocol2-account-provisioning` (2026-07-17) — booted ServUO + the real sidecar and exercised every endpoint (see §15). Part B phase 5 (Factions/VvV) deferred by owner decision.
|
|
||||||
**Date:** 2026-07-17
|
|
||||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
|
||||||
**Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
|
||||||
|
|
||||||
Protocol 1.0 shipped the read/event plane, the request/reply plane, `[link` account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue.
|
|
||||||
|
|
||||||
2.0 has **two scope areas**:
|
|
||||||
|
|
||||||
- **A — Account provisioning & unlinking (§1–§9).** The website can **create** accounts, **unlink** them, and the shard runs in one of three **signup modes** that decide which side may mint accounts. (1.0 could only *link* an account that already existed, and a link could never be undone.)
|
|
||||||
- **B — Social & political world-state streams (§10–§11).** Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Part A — Account provisioning & unlinking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What exists today, and the gap
|
|
||||||
|
|
||||||
| Capability | 1.0 | 2.0 |
|
|
||||||
|------------|-----|-----|
|
|
||||||
| Create account in-game (first-login auto-create) | ✔ `AccountHandler.cs:281` | unchanged |
|
|
||||||
| Link an **existing** game account to a website user | ✔ `[link` → `link.confirm` | unchanged |
|
|
||||||
| **Create** a game account from the website | ✗ | **new** `account.create` |
|
|
||||||
| **Unlink** a game account from its website user | ✗ | **new** `account.unlink` + `[unlink` |
|
|
||||||
| Choose which side may create accounts | ✗ (always in-game) | **new** signup mode |
|
|
||||||
|
|
||||||
**The linking flow is not changing.** `[link`, the one-time code, `link.confirm`, and the `WebsiteUserId` tag all stay exactly as they are (`BridgeAccountLink.cs`). 2.0 only *adds* verbs alongside them.
|
|
||||||
|
|
||||||
### The account-creation facts that shape this
|
|
||||||
|
|
||||||
- `new Account(username, password)` self-registers — its constructor calls `Accounts.Add(this)` (`Account.cs:186`) and `SetPassword` hashes per the shard's `AccountHandler.ProtectPasswords` (`Account.cs:174`). So creating an account from the bridge is `new Account(un, pw)` plus the link tag — no extra persistence layer, same as the `[link` tag reaching disk on the next world save.
|
|
||||||
- ServUO's in-game auto-create is gated on the **core** config `Accounts.AutoCreateAccounts` (default `true`, read once in `AccountHandler`'s static init, `AccountHandler.cs:29`). The bridge cannot intercept that path without a core edit, so the signup mode governs the **bridge's** `account.create` verb; the in-game side is controlled by pairing it with the matching core config (see §3).
|
|
||||||
- The core `CreateAccount` path (`AccountHandler.cs:494`) validates the username/password character set (printable ASCII `0x20–0x7F`, no forbidden chars) and enforces `MaxAccountsPerIP` (`Accounts.AccountsPerIp`, default **1**). The website path has **no `NetState`**, so the browser IP must be **passed through explicitly** to enforce that same cap (§3.1); and it must reuse the **character-safety** validation before `new Account`, or it can mint an account no client can log into (or that corrupts serialization).
|
|
||||||
- There is **no `EventSink.AccountCreated`**. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Signup modes — the model
|
|
||||||
|
|
||||||
A single shard-wide setting, `Bridge.SignupMode`, with three values. **Default `hybrid`.**
|
|
||||||
|
|
||||||
| Mode | `account.create` from website | In-game first-login auto-create | Who is the account authority |
|
|
||||||
|------|:-----------------------------:|:-------------------------------:|------------------------------|
|
|
||||||
| `website` | **accepted** | should be **off** | the website |
|
|
||||||
| `game` | **rejected** (`account.error`) | **on** | the game server |
|
|
||||||
| `hybrid` *(default)* | **accepted** | **on** | either side |
|
|
||||||
|
|
||||||
The bridge enforces exactly one half of this: whether it **honors `account.create`**. The other half — in-game auto-create — is the core `Accounts.AutoCreateAccounts` config, which the operator sets to match:
|
|
||||||
|
|
||||||
| `Bridge.SignupMode` | pair with `Accounts.AutoCreateAccounts` |
|
|
||||||
|---------------------|-----------------------------------------|
|
|
||||||
| `website` | `false` — otherwise any client that types a new name still mints an account, defeating website-only |
|
|
||||||
| `game` | `true` |
|
|
||||||
| `hybrid` | `true` |
|
|
||||||
|
|
||||||
On boot the bridge **reads `Accounts.AutoCreateAccounts` and warns** if it contradicts the selected mode (e.g. `SignupMode=website` while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture `BridgeConfig.ParseAccessLevel` already takes.
|
|
||||||
|
|
||||||
**Reconciliation in `hybrid`.** Both paths can race for the same username. `account.create` resolves it the only correct way: `Accounts.GetAccount(un) != null` → refuse with `account.error "account already exists"`. First writer wins; the loser gets a clean error, never a duplicate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. `account.create` — website-driven provisioning
|
|
||||||
|
|
||||||
The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created **and linked in one step** — no code exchange, because the website *is* the authority here (unlike `[link`, where the game side proves ownership with a code).
|
|
||||||
|
|
||||||
### Request (website → sidecar → shard)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech",
|
|
||||||
"account": "bob", "password": "hunter2", "websiteUserId": "9931",
|
|
||||||
"ip": "203.0.113.7" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `reqId` — correlation id, echoed on the reply (as everywhere else).
|
|
||||||
- `actor` — the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane).
|
|
||||||
- `account` — desired username.
|
|
||||||
- `password` — the game-client password the player chose on the site. Plaintext over the **loopback + token** socket, the same trust boundary every inbound verb already relies on; the shard hashes it via `SetPassword` immediately.
|
|
||||||
- `websiteUserId` — the site user to auto-link.
|
|
||||||
- `ip` — the **end user's browser IP**, so the shard can enforce `MaxAccountsPerIP` on website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trusted `X-Forwarded-For`); the **sidecar cannot derive it** — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1.
|
|
||||||
|
|
||||||
### Shard behavior (Core thread, in `BridgeAccounts.cs`)
|
|
||||||
|
|
||||||
1. **Gate.** `SignupMode == game` → `account.error "signups disabled for this mode"`. Master switch `Bridge.AccountCreateEnabled` (default follows mode) must be on.
|
|
||||||
2. **Validate `actor`** present (as admin plane does).
|
|
||||||
3. **Validate username/password** with the same character-safety rules as `AccountHandler.CreateAccount` (printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config.
|
|
||||||
4. **Collision check.** `Accounts.GetAccount(account) != null` → `account.error "account already exists"`.
|
|
||||||
5. **IP cap.** Parse `ip` → `IPAddress`. If `RequireIpForCreate` and it is missing/unparseable/loopback → `account.error "client ip required"` (**fail closed** — a missing IP must never silently bypass the cap; loopback is exempt in `IPLimiter`, so accepting it *is* a bypass). Then `AccountHandler.CanCreate(ip) == false` → `account.error "ip account limit reached"`. This is the same read-side check the in-game path runs at `AccountHandler.cs:510`.
|
|
||||||
6. **Create + link atomically.** `var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);` — `LogAccess` bumps `AccountHandler.IPTable[ip]` and records the IP into `LoginIPs` (`Account.cs:1251`), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds from `LoginIPs[0]` on reboot). The `WebsiteUserId` tag persists to `accounts.xml` on the next world save, identical to the `[link` path.
|
|
||||||
7. **Reply** `account.ok` and **emit** an unsolicited `account.audit` (`origin:"web"`, `action:"create"`) to every dashboard, parallel to `admin.audit`.
|
|
||||||
|
|
||||||
### Reply
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "account.ok", "reqId": "c1", "action": "create",
|
|
||||||
"account": "bob", "websiteUserId": "9931" }
|
|
||||||
{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 The IP flow — who sees what
|
|
||||||
|
|
||||||
```
|
|
||||||
browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard
|
|
||||||
(real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap)
|
|
||||||
```
|
|
||||||
|
|
||||||
The chain hops hosts, so the only party that sees the **end user's** IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the *website*, not the player — which is why `ip` is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or `X-Forwarded-For` from a proxy it trusts).
|
|
||||||
|
|
||||||
Two consequences to state plainly:
|
|
||||||
|
|
||||||
- **The IP is only as trustworthy as the website's proxy handling.** A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an *honesty* control against ordinary multi-account signups, not a hard security boundary against a hostile website.
|
|
||||||
- **IPv4/IPv6 skew.** A browser may present IPv6 while the UO client connects over IPv4; the two are different `IPAddress` keys, so a website account and a later in-game account from the "same" person may not share an `IPTable` bucket. Inherent to keying on raw IP — noted, not solved.
|
|
||||||
|
|
||||||
The sidecar itself does **not** validate or transform `ip`; it forwards the field and lets the shard (which owns `IPTable`) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless.
|
|
||||||
|
|
||||||
### Sidecar route
|
|
||||||
|
|
||||||
`POST /accounts/create`, body `{actor, account, password, websiteUserId, ip}` → the inbound line, correlated on a fresh `reqId`. Status mapping (new `respond_account`, modeled on `respond_admin`):
|
|
||||||
|
|
||||||
| Reply / reason | HTTP |
|
|
||||||
|----------------|------|
|
|
||||||
| `account.ok` | 200 |
|
|
||||||
| `"account already exists"` | 409 Conflict |
|
|
||||||
| `"ip account limit reached"` | 429 Too Many Requests |
|
|
||||||
| `"signups disabled…"` | 403 |
|
|
||||||
| `"client ip required"`, `"invalid username/password"`, missing field | 400 |
|
|
||||||
| shard down / timeout | 503 / 504 |
|
|
||||||
|
|
||||||
> ⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely: `account.audit`/`account.ok` **never carry the password**, and the console/audit log records only `account` + `actor`. This is the same discipline `BridgeEvents` already applies to the plaintext `AccountLoginEventArgs.Password` it deliberately never forwards (`PLAN.md` §12).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Unlinking
|
|
||||||
|
|
||||||
Symmetric with `[link`: either side can sever the tie. Both paths do the same one thing — remove the `WebsiteUserId` account tag (`acct.RemoveTag("WebsiteUserId")`) — and both persist on the next world save.
|
|
||||||
|
|
||||||
### 4.1 Website → `account.unlink`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Resolve by `account` (username) or `serial` (a player mobile's account), reusing `BridgeAdmin.ResolveTargetAccount`.
|
|
||||||
- Not linked → `account.error "not linked"` (a no-op is reported honestly, not faked as success).
|
|
||||||
- Apply the **Owner floor** (`BridgeAdmin.Protected`): refuse to unlink an account at/above `AdminAccessFloor`, same defense-in-depth as the admin verbs.
|
|
||||||
- Reply `account.ok action:"unlink"`; emit `account.audit action:"unlink"`.
|
|
||||||
|
|
||||||
Sidecar: `DELETE /link/{account}` (the existing `/link/:account` GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (`store.record_unlink`), so event attribution stops immediately without waiting on the shard.
|
|
||||||
|
|
||||||
### 4.2 In-game → `[unlink`
|
|
||||||
|
|
||||||
`CommandSystem.Register("unlink", AccessLevel.Player, …)` in `BridgeAccountLink.cs`, next to `[link`:
|
|
||||||
|
|
||||||
- Reads the caller's own account, clears the tag, emits `account.unlinked` (so the site learns of a player-initiated unlink and can reconcile its own record).
|
|
||||||
- Player-scoped: a player can only unlink **their own** account (no target argument), so it needs no floor.
|
|
||||||
- Symmetric UX with `[link`: `"Your account is no longer linked."`
|
|
||||||
|
|
||||||
> **Note — `[link` behavior is unchanged.** `[link` still refuses when a tag already exists (`BridgeAccountLink.cs:96`). `[unlink` is what clears it; after unlinking, `[link` works again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Trust & attribution
|
|
||||||
|
|
||||||
Identical model to the admin write plane (`ADMIN_CONTROLS.md` §5), because these are the same shape of action (website-authorized, applied on the loopback socket):
|
|
||||||
|
|
||||||
- **Authorization lives on the website.** `account.create`/`unlink` are gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the required `actor` field.
|
|
||||||
- **Owner floor** applies to `account.unlink` (never unlink a protected staff account from the web).
|
|
||||||
- **Attribution** is the `web:<actor>` string in the console line and the `account.audit` frame; the website keeps its own durable record, as it already does for `admin.audit`.
|
|
||||||
- **`account.create` now enforces `MaxAccountsPerIP`** using the browser IP the website forwards (§3.1), via the same `CanCreate` / `LogAccess` path as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails **closed** on a missing/loopback IP when `RequireIpForCreate` is on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Config keys (`Config/Bridge.cfg`)
|
|
||||||
|
|
||||||
```ini
|
|
||||||
SignupMode=hybrid # website | game | hybrid (default hybrid)
|
|
||||||
AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game
|
|
||||||
RequireIpForCreate=true # fail closed if account.create omits a usable browser IP
|
|
||||||
AccountNameMaxLength=16
|
|
||||||
AccountPasswordMaxLength=30
|
|
||||||
```
|
|
||||||
|
|
||||||
Read in `BridgeConfig.Load()`, re-readable via `[bridge reload`. `SignupMode` parses like `AdminAccessFloor` — unrecognized value falls back to the safest option (`game`, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. `RequireIpForCreate` defaults **on**: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then `MaxAccountsPerIP` still applies in-game as before).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Open item (not committed) — in-game → website creation sync
|
|
||||||
|
|
||||||
Per the 2026-07-17 decision, **this is not in 2.0's committed scope.** When an account is created *in-game* (first-login auto-create, or staff `[AddAccount`), the website is **not** notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten:
|
|
||||||
|
|
||||||
- **Why it's hard cleanly:** there is no `EventSink.AccountCreated`. The only faithful tap is a core edit — an `Action<Account>` raised in the `Account(string, string)` ctor (safe: the load path is a *separate* ctor, `Account.cs:189`, so it won't fire during world load), shipped as a `patches/` diff exactly like `PlayerVendorSale` and the `CommandLogging` event.
|
|
||||||
- **Why it may not be needed:** in `website`-mode the website already knows every account (it created them). Sync only matters for `hybrid`/`game` modes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from the `mob.login` `acct` field it already receives (first-seen = new), lossy but zero core edits.
|
|
||||||
- **If we do it later:** it becomes an `account.created` event stream (`origin:"in-game"`), the natural mirror of the `account.audit` (`origin:"web"`) that `account.create` emits — the same bidirectional-audit shape §5.5 of `ADMIN_CONTROLS.md` established. Revisit if a shard chooses `hybrid`/`game` and wants a complete website roster.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeAccounts.cs` | **New.** Registers `account.create` and `account.unlink`; the create+link, char-safety validation, collision check, Owner floor on unlink, `account.audit` emission. Mirrors `BridgeAdmin.cs` structure. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs` | **Extend.** Add the `[unlink` player command beside `[link`. No change to existing link behavior. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeConfig.cs` | **Extend.** `SignupMode` (parsed, safe fallback), `AccountCreateEnabled`, `RequireIpForCreate`, name/password length caps; read `Accounts.AutoCreateAccounts` and warn on mode mismatch. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeBoot.cs` | **Extend.** Wire `BridgeAccounts.Initialize()` into `Initialize()` (one line, beside the other subsystems). |
|
|
||||||
| `overlay/Config/Bridge.cfg` + `.example` | **Extend.** The §6 keys, defaults documented. |
|
|
||||||
| `sidecar/src/web.rs` | **Extend.** `POST /accounts/create` (forwards `ip` from the body untouched), `DELETE /link/:account`; `respond_account` status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. |
|
|
||||||
| `sidecar/src/store.rs` | **Extend.** `record_unlink` (clear the mirrored link row) beside the existing `record_link`. |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** Document `POST /accounts/create`, `DELETE /link/{account}`, and the `account.audit` event. |
|
|
||||||
| *(website, separate repo)* | Signup form → `POST /accounts/create`; unlink control → `DELETE /link/{account}`; consume `account.audit`. |
|
|
||||||
|
|
||||||
No new core/stock edits in committed scope — `account.create`/`unlink` are all script-layer (`new Account`, `SetTag`/`RemoveTag`) called from the new overlay. The only core edit contemplated (the `AccountCreated` event, §7) is explicitly deferred.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Phasing
|
|
||||||
|
|
||||||
1. ~~**Config + modes.**~~ **Done.** `BridgeConfig` gains `SignupMode` (parsed, unrecognized → `game`), `AccountCreateEnabled` (mode-following default), `RequireIpForCreate`, name/password caps, and the boot-time `Accounts.AutoCreateAccounts` mismatch warning. `[bridge status` shows `signup=…(create=…)`.
|
|
||||||
2. ~~**`account.create`.**~~ **Done.** `BridgeAccounts.cs` + `POST /accounts/create` + `respond_account`. Gate on mode, `actor` required, char-safety mirrored from `AccountHandler`, collision → 409, IP cap via `CanCreate`/`LogAccess` (fail-closed on missing/loopback IP when `RequireIpForCreate`), create + link, `account.audit`, password never logged/echoed. *Acceptance below is written for a live run — not yet exercised end-to-end.*
|
|
||||||
3. ~~**Unlink — both surfaces.**~~ **Done.** `account.unlink` + `DELETE /link/:account` + `store.record_unlink`, and the in-game `[unlink`. Owner floor reuses `BridgeAdmin.Protected`; `[unlink` emits `account.unlinked`.
|
|
||||||
4. ~~**Docs.**~~ **Done.** `INTEGRATION.md` §2 (protocol bumped to **2**), §4 (`account.audit`/`account.unlinked`), §6 (`POST /accounts/create`, `DELETE /link/{account}`), §7 (409/429).
|
|
||||||
|
|
||||||
**Build verification (2026-07-17):** sidecar `cargo check` clean; overlay compiled in the full ServUO Scripts tree — **0 errors, 0 warnings**. **Live end-to-end run still pending** (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same `ip` → 429, different IP succeeds, omitted IP → 400 while `RequireIpForCreate`), `LoginIPs[0]`/`IPTable` incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target.
|
|
||||||
|
|
||||||
Deferred (revisit only if a shard needs it): the §7 in-game→website `account.created` sync; the §12.3 `account.setpassword`/`account.exists` siblings; §12.5 credential-verb rate limiting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Part B — Social & political world-state streams
|
|
||||||
|
|
||||||
These are **outbound** streams (shard → website), the natural extension of `PLAN.md`'s event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline `BridgeSweeps` already established. Each entry below states its **grounded hook situation** so nothing rides an event that doesn't fire.
|
|
||||||
|
|
||||||
## 10. The requested streams
|
|
||||||
|
|
||||||
### 10.1 Guilds
|
|
||||||
|
|
||||||
**Hook reality (verified):**
|
|
||||||
|
|
||||||
- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`.
|
|
||||||
- `EventSink.CreateGuild` is **not** a creation notification. It is the load-time deserialization factory: raised only from `Server/World.cs:517` while reading the guild index at boot, where the handler's job is to *construct* the guild instance (`Guild.cs:775` → `new Guild(args.Id)`). Player guild creation (`new Guild(pm, name, abbrev)` at `Create Guild Gump.cs:83`, `GuildDeed.cs:127`) raises **no event**. **Do not use `CreateGuild` for "a guild was created"** — it would fire once per guild at every boot and never on an actual new guild.
|
|
||||||
- Leave, disband, leader change, alliance change, rename: **no events.**
|
|
||||||
|
|
||||||
**Delivery — a guild sweep + diff, exactly like house decay (`PLAN.md` §5.4).** `BaseGuild.List` is a `Dictionary<int, BaseGuild>` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary<int, GuildSnapshot>` (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff:
|
|
||||||
|
|
||||||
- id present now, absent before → `guild.created`
|
|
||||||
- id absent now, present before → `guild.disbanded`
|
|
||||||
- leader / alliance / name / abbreviation changed → `guild.updated`
|
|
||||||
- member set grew/shrank → `guild.join` / `guild.leave` (the sweep is the reliable source for leaves; `EventSink.JoinGuild` can *also* emit an immediate `guild.join` for joins, with the sweep as the backstop)
|
|
||||||
|
|
||||||
Take a **silent baseline** on `ServerStarted` (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading `Members.Count` + `Leader` is a handful of field reads each.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH",
|
|
||||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null}
|
|
||||||
{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13}
|
|
||||||
{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"}
|
|
||||||
```
|
|
||||||
|
|
||||||
> If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line `patches/` hook in `Scripts/Misc/Guild.cs` `RemoveMember`/`OnDelete` — the Phase-7 `patches/` precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front.
|
|
||||||
|
|
||||||
### 10.2 Town governors ("mayors")
|
|
||||||
|
|
||||||
In modern ServUO the "mayor of a town" is the **Governor** in the City Loyalty System (King Blackthorn's governance). Each `City` (enum, `CityLoyaltySystem.cs:15`) has a `CityLoyaltySystem` instance carrying `Governor` (Mobile), `GovernorElect`, an `Election`, a `Citizens` count, and a herald. The `Governor` setter already broadcasts a herald message on change (`CityLoyaltySystem.cs:193`), confirming a governor transition is a first-class in-game event — there just isn't an `EventSink` for it.
|
|
||||||
|
|
||||||
**Delivery — a city sweep, emit-on-change.** `CityLoyaltySystem.Cities` (static `List<CityLoyaltySystem>`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary<City, governorSerial>`, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"},
|
|
||||||
"to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}}
|
|
||||||
{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Gate on `CityLoyaltySystem.Enabled`** (`CityLoyalty.Enabled`, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume.
|
|
||||||
|
|
||||||
### 10.3 Player titles
|
|
||||||
|
|
||||||
There is **no title-change event.** Titles are read-model state, best delivered two ways, not as a stream:
|
|
||||||
|
|
||||||
- **Enrich `char.profile`** (`BridgeProfile`) with a `titles` block. Sources on a `PlayerMobile`: the reward-title list `m_RewardTitles` (`List<object>`) + the selected index `m_SelectedTitle` (`PlayerMobile.cs:4194,4595`), the champion title `m_CurrentChampTitle`, plus the computed titles from `Titles.ComputeTitle` / `ComputeFameTitle` / `GetSkillTitle` / veteran titles (`Scripts/Misc/Titles.cs`). `char.profile` already carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a *read* — no hook needed.
|
|
||||||
- **Optional `title.change`** only if the community page wants a live "so-and-so is now *Grandmaster Blacksmith*" feed — and then it comes from a **profile-diff in the sidecar**, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand.
|
|
||||||
|
|
||||||
City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, `MerchantTitles.cs`) fold into the same `titles` block.
|
|
||||||
|
|
||||||
### 10.4 Factions / Vice vs Virtue
|
|
||||||
|
|
||||||
**Which system is live is a shard decision — verify before building.** Two exist:
|
|
||||||
|
|
||||||
- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List<PlayerState>`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**.
|
|
||||||
- **Vice vs Virtue** (`Scripts/Services/ViceVsVirtue`): the modern replacement. `ViceVsVirtueSystem.Enabled` (`VvV.Enabled`, default **true**), a singleton `Instance`, an active `Battle`, and per-player `VvVPlayerEntry` (score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2.
|
|
||||||
|
|
||||||
**Delivery — a sweep, gated on whichever is enabled.** Neither system raises membership/leadership `EventSink`s, so it is the same sweep+diff pattern:
|
|
||||||
|
|
||||||
- VvV (recommended default): standings per side, active-battle status (`Battle.OnGoing`, current city), and the top `VvVPlayerEntry` scores → a `vvv.standings` snapshot on change + a `vvv.battle` open/close event.
|
|
||||||
- Old Factions (only if a shard runs it): `faction.control` (town → owning faction on change), `faction.commander` (leader change from the `Election`).
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"}
|
|
||||||
{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing.
|
|
||||||
|
|
||||||
## 11. Further integration points — a menu to pick from
|
|
||||||
|
|
||||||
Everything below is grounded in a hook or a cheap sweep in *this* server. Ranked roughly by value-to-effort. **Pick the ones you want and I'll fold them into the phasing.** (✔ = a real `EventSink` exists; ⟳ = sweep/diff; ⚑ = needs a small `patches/` core tap.)
|
|
||||||
|
|
||||||
| # | Stream | Source | Effort | Why it's worth it |
|
|
||||||
|---|--------|--------|:------:|-------------------|
|
|
||||||
| 1 | **Who's-online / population** | ⟳ online sweep over `NetState.Instances` | low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. |
|
|
||||||
| 2 | **Region presence** | ✔ `EventSink.OnEnterRegion` (`Region.cs:1160`, player-filtered) | low | Cheap location stream → town population heatmap, "who's in Despise" — `PLAN.md` §5.6 already flags it as the right answer over `Movement`. |
|
|
||||||
| 3 | **Crafting feed** | ✔ `EventSink.CraftSuccess` | low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. |
|
|
||||||
| 4 | **Taming feed** | ✔ `EventSink.TameCreature` | low | New tames, esp. rares/greaters — high community interest. |
|
|
||||||
| 5 | **Resource harvesting** | ✔ `EventSink.ResourceHarvestSuccess` | low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). |
|
|
||||||
| 6 | **Virtue progression** | ✔ `EventSink.VirtueLevelChange` | low | Knight/Seeker/etc. virtue ranks — a progression badge system. |
|
|
||||||
| 7 | **Bulk Order Deeds** | ✔ `EventSink.BODOffered` / `BODUsed` | low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. |
|
|
||||||
| 8 | **Guild wars** | ⟳ from the §10.1 guild sweep (war state on `Guild`) | low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. |
|
|
||||||
| 9 | **Player housing registry** | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses `PLAN.md` §5.4 machinery. |
|
|
||||||
| 10 | **Peerless / boss / rare drops** | ⚑ virtual-override or drop-system tap (no `EventSink`) | med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, `PLAN.md` §5.9) — needs a targeted `patches/` hook, so it is a deliberate pick, not a freebie. |
|
|
||||||
| 11 | **Secure player trades** | ⚑ `SecureTrade` completion has no `EventSink` | med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. |
|
|
||||||
| 12 | **Champion spawn *board*** | already shipped (`BridgeChamps`) — extend, don't rebuild | — | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. |
|
|
||||||
|
|
||||||
**Selected for Part B (owner pick, 2026-07-17):** guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + **housing registry (#9)** + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13.
|
|
||||||
|
|
||||||
### Where the Part B code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeSocial.cs` | **New.** The guild sweep+diff and the `EventSink.JoinGuild` subscription → `guild.*`. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeGovernance.cs` | **New.** The city sweep → `city.governor`/`city.election`; the VvV/faction standings sweep (gated on enabled) → `vvv.*` / `faction.*`; the one-time `world.systems` frame. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeProfile.cs` | **Extend.** Add the `titles` block to `char.profile`. |
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeSweeps.cs` | **Extend / mirror.** New sweep timers (guild, city, presence), re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`, counters in `[bridge status` — same shape as the existing sweeps. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | **Extend.** `GuildSweepSeconds`, `CitySweepSeconds`, `PresenceSweepSeconds` (+ enable flags). |
|
|
||||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the snapshots that back boards (guild roster, governors, population history); `GET /guilds`, `/governors`, `/online` served from the store so they survive a shard outage, exactly like `/champs` and `/economy` do today. |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** New event catalog entries + the read endpoints. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Cross-cutting additions (recommended)
|
|
||||||
|
|
||||||
Five things that are not new *streams* but make 2.0 correct and complete. The first two I consider **essential**; the rest are high-value companions to what's already specced.
|
|
||||||
|
|
||||||
### 12.1 Bump the protocol version to 2 — **essential**
|
|
||||||
|
|
||||||
The sidecar is `PROTOCOL_VERSION = 1` (`sidecar/src/main.rs:23`), and every response carries `X-UOLink-Version`; the gate 409s a client that declares a different one (`web.rs:146`). 2.0 adds inbound verbs (`account.create`, `account.unlink`, …) and event kinds, so it must bump to `2`.
|
|
||||||
|
|
||||||
The compatibility rule to write down: **new outbound event kinds are additive** — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is *not* compatible is a client that calls a **new inbound verb** against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to `2`, keep the feed additive, and document that the new *verbs/endpoints* require a v2 sidecar while the *event feed* degrades gracefully.
|
|
||||||
|
|
||||||
### 12.2 Every diff stream needs a REST snapshot companion — **essential**
|
|
||||||
|
|
||||||
The Part B streams are **diff-based**: `guild.created`/`disbanded`, `city.governor`, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a **sidecar that restarts** — has seen *no* deltas yet and therefore has **no current state**. The live feed alone can never answer "what are the guilds *right now*."
|
|
||||||
|
|
||||||
So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as `/champs` and `/economy` already are (`web.rs`): `GET /guilds`, `/governors`, `/online`, `/houses`. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change.
|
|
||||||
|
|
||||||
> Concretely: the sidecar keeps a `guilds` / `governors` / `population` table updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (a `snapshot.request` inbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild.
|
|
||||||
|
|
||||||
### 12.3 Round out the provisioning surface — password reset, existence check
|
|
||||||
|
|
||||||
`account.create` sets the account's **initial** password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded:
|
|
||||||
|
|
||||||
- **`account.setpassword`** — the **later** password *change/reset* for an account that already exists (a player who forgot theirs), distinct from the initial password `account.create` sets. `acct.SetPassword(newpw)` (`Account.cs:676`) is public; the verb takes `{actor, account, password}`, applies the Owner floor, emits `account.audit action:"setpassword"`, and — like create — **never echoes the password**. `POST /accounts/{account}/password`. Only worth building if the site will offer a "forgot password" flow.
|
|
||||||
- **`account.exists`** — the signup form wants to say "that name is taken" before submit. A read: `Accounts.GetAccount(un) != null`. `GET /accounts/{account}` → `{exists: true|false, linked: bool}`. Cheap, and it prevents the worse UX of finding out via a 409 on submit.
|
|
||||||
|
|
||||||
Both reuse the `account.*` machinery from Part A verbatim. `account.setpassword` is the higher-value of the two.
|
|
||||||
|
|
||||||
### 12.4 Mirror hygiene — deletion & link teardown
|
|
||||||
|
|
||||||
If the website mirrors rosters/links (it does — `store.record_link`), it must learn when the game side removes things, or the mirror rots:
|
|
||||||
|
|
||||||
- **Character deletion.** `EventSink.DeleteRequest` (`EventSink.cs:1754`) fires when a player deletes a character at the select screen. Emit `char.deleted` so the website drops it from any roster it caches. (`PLAN.md` §5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.)
|
|
||||||
- **Account deletion.** `Account.Delete()` exists (`Account.cs:642`); an optional `account.delete` verb (Owner-floor-guarded, `origin:"web"` audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record.
|
|
||||||
- On any unlink **or** account delete, the sidecar clears its link mirror (the `record_unlink` already specced in §4.1), so event attribution stops immediately.
|
|
||||||
|
|
||||||
### 12.5 Rate-limit the credential verbs
|
|
||||||
|
|
||||||
`account.create` and `account.setpassword` mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a **sidecar-side rate limit** on the credential verbs — a global create-per-minute ceiling and a per-`actor` cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (`BridgeConfig.TownCrier*`, `Admin*`). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Part B phasing
|
|
||||||
|
|
||||||
1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.*
|
|
||||||
2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.*
|
|
||||||
3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.*
|
|
||||||
4. ~~**Titles.**~~ **Built (2026-07-17), compiles clean.** `char.profile` gains a `titles` block (`selected`, `fameKarma`, `skill`, `reward[]`) from `PlayerMobile` accessors — no new stream, folds into `BridgeProfile`. *Live run pending.*
|
|
||||||
5. **Factions/VvV** — **deferred** (owner decision): only after confirming which system the shard runs; stream just the enabled one.
|
|
||||||
|
|
||||||
Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar
|
|
||||||
|
|
||||||
ServUO ships a **Reports engine** (`Server.Engines.Reports`, `Scripts/Services/Reports/`) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and **FTP it to your website**. The bridge can tap the *compiled data* directly and ship JSON, retiring the file/FTP path entirely. **No core edit** — the compile methods are `public static`.
|
|
||||||
|
|
||||||
### 14.1 What the engine produces (verified)
|
|
||||||
|
|
||||||
`Reports.Generate()` runs hourly on the Core thread and builds a `Snapshot` from public static compile methods (`Reports.cs`):
|
|
||||||
|
|
||||||
| Method | Returns | Content |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| `CompileGeneralStats()` | `Report` | NPCs, Players, Clients, Accounts, Items |
|
|
||||||
| `CompileStatChart()` | `Chart` | population over time |
|
|
||||||
| `CompileSkillReports()` | `PersistableObject[]` | **skill distribution — GM count per skill** |
|
|
||||||
| `CompileFactionReports()` | `PersistableObject[]` | faction membership / stats |
|
|
||||||
| `Reports.StaffHistory` | `StaffHistory` | staff activity per account (`StaffInfo`/`UserInfo` hashtables), **help-page-queue length over time** (`QueueStats`), page history |
|
|
||||||
|
|
||||||
Each `Report` is structured (`Columns` + `Items`), so it serializes to JSON cleanly with the hand-rolled `BridgeJson` writers — no reflection serializer. The engine also persists an hourly **`SnapshotHistory`** series to disk, so a backfill of historical points is available if wanted.
|
|
||||||
|
|
||||||
### 14.2 How it's delivered today (the file path you flagged)
|
|
||||||
|
|
||||||
- **HTML + FTP.** `UpdateOutput` (`Reports.cs:406`, on a ThreadPool thread) runs `HtmlRenderer` into `<BaseDir>/reports/stats/` and `reports/staff/` (`Reports.Path`, default `reports`), then `Upload()` writes an `upload.ftp` job to FTP the HTML to a website. Gated on `Reports.AutoGenerate` (**default off**).
|
|
||||||
- **WebStatus.** A *separate* mechanism (`Scripts/Misc/WebStatus.cs`): an in-process `HttpListener` on `:80/status/` serving a live status HTML page. Default `Enabled = false`.
|
|
||||||
|
|
||||||
Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (`/health` + the live feed cover what `WebStatus` served); §14 replaces the first.
|
|
||||||
|
|
||||||
### 14.3 The tap — a report sweep, JSON out
|
|
||||||
|
|
||||||
`BridgeReports.cs` runs a **Core-thread timer** (`ReportSweepSeconds`, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the `Report`/`Chart` objects to JSON, emits `report.*`, and hands the sidecar a snapshot to persist and serve over REST:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{"kind":"report.skills","t":1752…,"skills":[
|
|
||||||
{"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]}
|
|
||||||
{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467}
|
|
||||||
{"kind":"report.staff","window":"7d","staff":[
|
|
||||||
{"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]}
|
|
||||||
```
|
|
||||||
|
|
||||||
Served for hydration (the §12.2 snapshot rule): `GET /reports/skills`, `/reports/general`, `/reports/staff`.
|
|
||||||
|
|
||||||
Key points, all grounded:
|
|
||||||
|
|
||||||
- **No core edit, no HTML, no FTP.** Calling `Compile*` directly skips `HtmlRenderer`/`Upload` entirely. Leave `Reports.AutoGenerate` **off** (no HTML files written) and run the bridge tap instead. The FTP `upload.ftp` path and `Reports.Path` become dead weight for a bridge-connected shard.
|
|
||||||
- **Threading.** `Compile*` read `World.Mobiles`/`Skills`, so they must run on the Core thread — which the bridge's sweep timers already are (`PLAN.md` non-negotiables). Stock only offloaded the *HTML rendering* (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning in `PLAN.md` §1: run it on a slow cadence (hourly is plenty), never in a fast sweep.
|
|
||||||
- **Dedupe against Part B.** `report.general` and the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The **unique** wins here are **skill distribution** (a GM-per-skill leaderboard available nowhere else in the bridge) and the **staff-activity + page-queue-length history** (aggregates that complement the per-action `admin.audit` we already stream). Prioritize those two; treat the rest as "already covered, don't double-emit."
|
|
||||||
- **Optional backfill.** On first connect the sidecar could ingest the engine's persisted `SnapshotHistory` (`Reports.StaffHistory`/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required.
|
|
||||||
|
|
||||||
### 14.4 Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeReports.cs` | **New.** Core-thread report sweep calling `Reports.Compile*` + `Reports.StaffHistory`; serialize to `report.*`; re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | **Extend.** `ReportSweepSeconds` (+ enable flag). |
|
|
||||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the report snapshots; `GET /reports/{skills,general,staff}` served from the store (survives shard outage, like `/champs`). |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. |
|
|
||||||
|
|
||||||
> **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Smoke test — live run (2026-07-17)
|
|
||||||
|
|
||||||
Deployed the overlay to the ServUO checkout, booted the shard and the real sidecar (protocol 2, `plugin_connected: true`), and exercised every new surface over REST against the live game. All green.
|
|
||||||
|
|
||||||
**Part A — provisioning (through the real shard):**
|
|
||||||
|
|
||||||
| Check | Result |
|
|
||||||
|-------|--------|
|
|
||||||
| `POST /accounts/create` (fresh IP) | **200** `account.ok`, account created + linked |
|
|
||||||
| duplicate name | **409** `account already exists` |
|
|
||||||
| per-IP cap | shard's real `AccountsPerIp=3` enforced: 3rd from one IP allowed, **4th → 429** `ip account limit reached` |
|
|
||||||
| loopback IP with `RequireIpForCreate` | **400** `client ip required` (fails closed) |
|
|
||||||
| `GET /link/{acct}` after create | **200**, linked to the website id |
|
|
||||||
| `DELETE /link/{acct}` | **200** `unlink`; lookup then **404** |
|
|
||||||
|
|
||||||
**Part B — world-state boards (through the real shard):**
|
|
||||||
|
|
||||||
| Endpoint | Result |
|
|
||||||
|----------|--------|
|
|
||||||
| `GET /houses` | **28 houses**, full owner/decay/co-owner/built-on data (shard → `house.update` → board → REST) |
|
|
||||||
| `GET /governors` | **9 cities**, `governor: null`/`electionPhase: none` on this unseeded world |
|
|
||||||
| `GET /guilds` | `[]` — no guilds on this world; the sweep ran without error |
|
|
||||||
| `GET /online` | `count: 0` — headless (no UO client), snapshot emitted and stored |
|
|
||||||
| `GET /char/{acct}/0` | full profile incl. the new `titles` block |
|
|
||||||
|
|
||||||
**Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path.
|
|
||||||
|
|
||||||
**One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Town Cryer news — website articles into the news gump (Protocol 2.1)
|
|
||||||
|
|
||||||
**Status:** **Built and smoke-tested live** (2026-07-17). `BridgeNews.cs` (pure overlay, no stock edit) + `POST /news` / `DELETE /news/{id}` + reconnect replay. Verified against a booted shard: `news.add` (full + title-only) → `news.ok`, missing title → 400, idempotent replace, `news.remove` → `news.ok`, unknown id → `news.error`, no shard exceptions, and the **reconnect replay** confirmed (after a shard restart the stored article was re-pushed with `announce:false` and re-accepted). The gump rendering itself is verified by source inspection (needs a UO client to view).
|
|
||||||
|
|
||||||
There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first:
|
|
||||||
|
|
||||||
1. **The scrolling crier** (`GlobalTownCrierEntryList`) — the wandering Town Crier NPC that *says* short announcement lines. Protocol 1.0 phase 6 (`BridgeTownCrier.cs`, `towncrier.add`/`remove`) already drives this.
|
|
||||||
2. **The Town Cryer News gump** (`TownCryerSystem.NewsEntries`) — the paged news UI with title + body + image + a "more info" URL per article. **Nothing drives this yet.** This section adds it.
|
|
||||||
|
|
||||||
The ask: a website news article should land as a full article in the **news gump** (2), and the crier should also *say* just the **title** through the existing say feature (1) — so players get the audible "Hear ye!" proclamation while the full write-up lives in the gump.
|
|
||||||
|
|
||||||
### 16.1 The hook (verified in the shard's source)
|
|
||||||
|
|
||||||
- **`TownCryerSystem.NewsEntries`** (`TownCryerSystem.cs:40`) — `public static List<TownCryerNewsEntry>`. The setter is private, but the **list is public and mutable**, so it can be inserted into and removed from directly.
|
|
||||||
- **`TownCryerNewsEntry(TextDefinition title, TextDefinition body, int gumpImage, Type questType, string url)`** (`TownCryerNewsEntry.cs`) — public ctor. Pass `questType: null` for website news.
|
|
||||||
- **The display gumps already handle string content**, so no gump edits are needed:
|
|
||||||
- List view (`TownCryerGump.cs:97-103`): `if (entry.Title.Number > 0) AddHtmlLocalized(...) else AddLabelCropped(..., entry.Title)`.
|
|
||||||
- Detail view (`TownCryerNewsGump.cs:27-42`): `if (Entry.Body.Number > 0) AddHtmlLocalized(...) else AddHtml(..., Entry.Body.String, ..., true)` — a **string body renders as HTML** (so `<CENTER>…</CENTER><BR><BR>…` works), `AddImage(..., Entry.GumpImage)`, and `InfoUrl` becomes a `LaunchBrowser` button.
|
|
||||||
- **Stock news is live on this shard.** `TownCryerSystem.Initialize()` adds ~18 hardcoded `uo.com` entries whenever `TownCryerSystem.Enabled` (`TownCryerSystem.cs:93-120`) — *not* gated by `UsePreloadedMessages` (that only gates a reload command). So the list is not empty, and our sync must not clobber it (see §16.3).
|
|
||||||
|
|
||||||
### 16.2 Evaluating the pasted guidance
|
|
||||||
|
|
||||||
The pasted analysis is **substantially correct** and useful — it identifies the right hook (`NewsEntries`), the right constructor, the cliloc-vs-string branching the gump already does, the image/url fields, and the important instinct to keep stock news separate. Two adjustments for *this* architecture:
|
|
||||||
|
|
||||||
- **No stock patch is needed.** The pasted plan adds `AddNewsEntry` / `ClearExternalNews` methods to the stock `TownCryerSystem.cs`. That file is stock ServUO, so editing it would ship as a `patches/` diff (like `PlayerVendorSale`). We can avoid that entirely: because `NewsEntries` is a **public mutable list**, the bridge overlay inserts and removes directly — `TownCryerSystem.NewsEntries.Insert(0, entry)` / `.Remove(entry)` — and keeps the "which entries are ours" bookkeeping in an **overlay-side list**, not in a new field on the stock class. This is exactly how `BridgeTownCrier` already mutates `GlobalTownCrierEntryList` from the overlay. Pure overlay, zero stock edits.
|
|
||||||
- **Track our entries to keep stock intact.** Rather than the pasted `ExternalNewsEntries` field on the stock class, the overlay holds `List<TownCryerNewsEntry> _ours`. On a sync we `Remove` our previous entries from `NewsEntries` and insert the new set — the stock `uo.com` articles are never touched. `MaxNewsEntries` is 100 (`TownCryerSystem.cs:26`); the overlay caps its own contribution well under that.
|
|
||||||
|
|
||||||
Everything else in the pasted note stands, and the "this is one of the easier integrations — you're replacing the content provider" framing is right.
|
|
||||||
|
|
||||||
### 16.3 The two surfaces, tied together
|
|
||||||
|
|
||||||
On an inbound article the bridge does two things on the Core thread:
|
|
||||||
|
|
||||||
1. **News gump** — build `new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)` and `Insert(0, …)` at the top of `TownCryerSystem.NewsEntries`, tracking it in `_ours`; trim `_ours` past the cap by removing the oldest (from both `_ours` and `NewsEntries`).
|
|
||||||
2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. **On by default**; set `announce: false` on an article to suppress it (e.g. a silent correction that should not re-proclaim).
|
|
||||||
|
|
||||||
### 16.4 Protocol
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// website → sidecar → shard
|
|
||||||
{"kind":"news.add","id":"42","title":"Double XP Weekend",
|
|
||||||
"body":"<CENTER>Double XP Weekend</CENTER><BR><BR>Starts Friday 7PM.",
|
|
||||||
"image":1614,"url":"https://uomysticmoon.com/news/42"}
|
|
||||||
// announce defaults to true; add "announce":false to suppress the crier proclamation
|
|
||||||
{"kind":"news.remove","id":"42"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Correlated by `id` (echoed on the reply), like town-crier. Re-adding an `id` **replaces** the prior entry (find-by-id in `_ours`, remove, re-insert) — idempotent.
|
|
||||||
- `title` required; `body`/`image`/`url` optional (a title-only blurb is valid). `image` defaults to a neutral scroll gump id when absent.
|
|
||||||
- Caps (defense in depth, mirroring `TownCrier*`): title/body length, max external entries. Replies `news.ok` / `news.error`.
|
|
||||||
- Sidecar: `POST /news` (add/replace), `DELETE /news/{id}`. Same `respond`-style status mapping as town-crier.
|
|
||||||
|
|
||||||
### 16.5 Restart & re-sync (the source-of-truth rule)
|
|
||||||
|
|
||||||
`NewsEntries` is **not persisted** by ServUO — it is rebuilt at every boot from stock `Initialize()` plus whatever we have inserted since. So our external articles vanish on a shard restart until re-pushed. The **website is the source of truth**: the sidecar re-sends the current external news set on every shard (re)connect, the same discipline §12.2 uses for the diff boards. (The sidecar persists the external set in its store so it can replay it without the website being up.)
|
|
||||||
|
|
||||||
### 16.6 Where the code goes
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `overlay/Scripts/Custom/Bridge/BridgeNews.cs` | **New.** `news.add` / `news.remove`: insert/remove `TownCryerNewsEntry` in the public `NewsEntries` list, track `_ours`, cap; optional title announcement via `GlobalTownCrierEntryList`; replies + caps. No stock edit. |
|
|
||||||
| `overlay/Config/Bridge.cfg` | **Extend.** `NewsMaxTitleLength`, `NewsMaxBodyLength`, `NewsMaxExternal`, default announce duration. |
|
|
||||||
| `sidecar/src/web.rs` + `store.rs` | **Extend.** `POST /news`, `DELETE /news/{id}`; persist the external-news set; replay it on shard (re)connect. |
|
|
||||||
| `docs/INTEGRATION.md` | **Extend.** The `news.*` verbs + endpoints. |
|
|
||||||
|
|
||||||
No core or stock ServUO change — the whole integration rides the public `TownCryerSystem.NewsEntries` list and the existing crier say path.
|
|
||||||
544
docs/RESEARCH.md
544
docs/RESEARCH.md
@@ -1,544 +0,0 @@
|
|||||||
# ServUO ⇄ External Service Bridge — Research Findings
|
|
||||||
|
|
||||||
**Status:** Research only, no implementation.
|
|
||||||
**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account).
|
|
||||||
**Date:** 2026-07-07
|
|
||||||
**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**.
|
|
||||||
**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Note on empirical verification
|
|
||||||
|
|
||||||
You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would:
|
|
||||||
|
|
||||||
- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default),
|
|
||||||
- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report),
|
|
||||||
- run `EventSink.ServerStarted` and AutoSave against real state.
|
|
||||||
|
|
||||||
That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because:
|
|
||||||
|
|
||||||
1. The source pins the threading model exactly (call sites shown below), and
|
|
||||||
2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack:
|
|
||||||
|
|
||||||
```
|
|
||||||
Server.EventSink.InvokeClientVersionReceived(...)
|
|
||||||
Server.Network.MessagePump.HandleReceive(NetState ns)
|
|
||||||
Server.Network.MessagePump.Slice()
|
|
||||||
Server.Core.Main(String[] args)
|
|
||||||
```
|
|
||||||
|
|
||||||
That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime.
|
|
||||||
|
|
||||||
If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it.
|
|
||||||
|
|
||||||
> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST)
|
|
||||||
|
|
||||||
**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account.
|
|
||||||
|
|
||||||
The §1–§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7.
|
|
||||||
|
|
||||||
### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb
|
|
||||||
|
|
||||||
```
|
|
||||||
ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
|
||||||
(main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.**
|
|
||||||
|
|
||||||
- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard.
|
|
||||||
- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`).
|
|
||||||
- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it.
|
|
||||||
|
|
||||||
**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard.
|
|
||||||
|
|
||||||
### II.2 Tracking targets → concrete hooks (and the gaps)
|
|
||||||
|
|
||||||
| Target | Hook | Freq | Notes / caveats |
|
|
||||||
|--------|------|------|-----------------|
|
|
||||||
| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. |
|
|
||||||
| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 15–30 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. |
|
|
||||||
| **Gold (per player)** | `EventSink.AccountGoldChange` | Low–Med | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. |
|
|
||||||
| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. |
|
|
||||||
| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. |
|
|
||||||
| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. |
|
|
||||||
| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." |
|
|
||||||
| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. |
|
|
||||||
|
|
||||||
> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path.
|
|
||||||
|
|
||||||
### II.3 The `[link` command flow
|
|
||||||
|
|
||||||
Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O.
|
|
||||||
|
|
||||||
1. **Register** in your plugin's `Initialize()`:
|
|
||||||
`CommandSystem.Register("link", AccessLevel.Player, OnLink);`
|
|
||||||
2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 6–8 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and:
|
|
||||||
- push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and
|
|
||||||
- `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")`
|
|
||||||
3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`.
|
|
||||||
4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online.
|
|
||||||
5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user.
|
|
||||||
|
|
||||||
Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret.
|
|
||||||
|
|
||||||
### II.4 Revised flags for THIS architecture
|
|
||||||
|
|
||||||
1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO.
|
|
||||||
2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 15–30 s snapshot of online players; don't expect push-on-change.
|
|
||||||
3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece.
|
|
||||||
4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply.
|
|
||||||
5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves.
|
|
||||||
6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly.
|
|
||||||
7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`).
|
|
||||||
8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART III — Player-vendor tracking, IDOC, town-crier news, config
|
|
||||||
|
|
||||||
Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**.
|
|
||||||
|
|
||||||
### III.1 Player-vendor sales — the one place you need a small core touch
|
|
||||||
|
|
||||||
There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// PlayerVendorGumps.cs ~line 81-96 (existing code)
|
|
||||||
leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack
|
|
||||||
if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank
|
|
||||||
...
|
|
||||||
commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100));
|
|
||||||
m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed
|
|
||||||
```
|
|
||||||
|
|
||||||
At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage.
|
|
||||||
|
|
||||||
**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else:
|
|
||||||
|
|
||||||
1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`).
|
|
||||||
2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96:
|
|
||||||
`EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));`
|
|
||||||
3. Bridge subscribes in `Initialize` like any other event.
|
|
||||||
|
|
||||||
This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.)
|
|
||||||
|
|
||||||
**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread).
|
|
||||||
|
|
||||||
### III.2 Config-editable sweep interval (and other tunables)
|
|
||||||
|
|
||||||
Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
StatSweep = Config.Get("Bridge.StatSweepSeconds", 30);
|
|
||||||
DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60);
|
|
||||||
```
|
|
||||||
|
|
||||||
Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get<T>` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg.
|
|
||||||
|
|
||||||
### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition
|
|
||||||
|
|
||||||
Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`):
|
|
||||||
|
|
||||||
- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.0–99.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%.
|
|
||||||
- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them.
|
|
||||||
- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event.
|
|
||||||
|
|
||||||
**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (30–60 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary<Serial, DecayLevel>` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass.
|
|
||||||
|
|
||||||
**State & re-baseline (important, since the plugin now holds state):**
|
|
||||||
- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition).
|
|
||||||
- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal.
|
|
||||||
- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly.
|
|
||||||
|
|
||||||
**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus:
|
|
||||||
|
|
||||||
| Field | Source |
|
|
||||||
|-------|--------|
|
|
||||||
| house serial | `house.Serial` |
|
|
||||||
| decay from → to | tracked dict → `house.DecayLevel` |
|
|
||||||
| coords + facet | `house.X/Y/Z`, `house.Map` |
|
|
||||||
| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) |
|
|
||||||
| region / area name | `house.Region` (`:3672`) → `Region.Name` |
|
|
||||||
| house name | `house.Sign?.GetName()` (`:2108`) |
|
|
||||||
| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) |
|
|
||||||
| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts |
|
|
||||||
| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) |
|
|
||||||
| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` |
|
|
||||||
|
|
||||||
Example emit:
|
|
||||||
```jsonc
|
|
||||||
{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC",
|
|
||||||
"map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0},
|
|
||||||
"region":"Britain", "name":"The Silver Anvil",
|
|
||||||
"owner":{"serial":"0x1A2B","account":"PerryAdimn"},
|
|
||||||
"coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...",
|
|
||||||
"collapseEta":"2026-07-08T..." }
|
|
||||||
```
|
|
||||||
|
|
||||||
This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses).
|
|
||||||
|
|
||||||
### III.4 Town-crier news pushed from the website (inbound → main thread)
|
|
||||||
|
|
||||||
Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`.
|
|
||||||
|
|
||||||
**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`.
|
|
||||||
|
|
||||||
Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news.
|
|
||||||
|
|
||||||
### III.5 Updated capability map
|
|
||||||
|
|
||||||
| Capability | Mechanism | Core touch? | Runs on |
|
|
||||||
|-----------|-----------|:-----------:|---------|
|
|
||||||
| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread |
|
|
||||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread |
|
|
||||||
| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread |
|
|
||||||
| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread |
|
|
||||||
| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread |
|
|
||||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) |
|
|
||||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
|
||||||
|
|
||||||
**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PART IV — Full character profiles (armor / weapons / skills / everything)
|
|
||||||
|
|
||||||
You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.*
|
|
||||||
|
|
||||||
### IV.1 It's all on the live `Mobile` — and offline chars stay resident
|
|
||||||
|
|
||||||
- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster.
|
|
||||||
- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag.
|
|
||||||
- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`.
|
|
||||||
- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable<Skill>` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58.
|
|
||||||
- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them.
|
|
||||||
- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names.
|
|
||||||
|
|
||||||
### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly)
|
|
||||||
|
|
||||||
A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility:
|
|
||||||
|
|
||||||
| Tier | Contents | When emitted |
|
|
||||||
|------|----------|--------------|
|
|
||||||
| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events |
|
|
||||||
| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** |
|
|
||||||
|
|
||||||
**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.)
|
|
||||||
|
|
||||||
### IV.3 Character-profile schema (sketch)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"kind": "char.profile",
|
|
||||||
"account": "PerryAdimn", "slot": 0,
|
|
||||||
"serial": "0x0075", "name": "Thunderheat", "title": "the Legendary",
|
|
||||||
"body": 400, "hue": 33770, "online": true,
|
|
||||||
"stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100,
|
|
||||||
"mana":40,"manaMax":45,"stam":88,"stamMax":90,
|
|
||||||
"resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62},
|
|
||||||
"gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 },
|
|
||||||
"skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"},
|
|
||||||
{"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ],
|
|
||||||
"equipment": [
|
|
||||||
{ "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0,
|
|
||||||
"name":null,"cliloc":1023721, // resolve name via cliloc (IV.4)
|
|
||||||
"weapon":{"minDamage":16,"maxDamage":18,"strReq":40},
|
|
||||||
"mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} },
|
|
||||||
{ "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157,
|
|
||||||
"name":"Ancient Plate","armor":{"baseRating":45},
|
|
||||||
"mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} }
|
|
||||||
],
|
|
||||||
"vendorsOwned": 3 // future (IV.5)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags.
|
|
||||||
|
|
||||||
### IV.4 Gotchas for the profile export
|
|
||||||
|
|
||||||
- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present.
|
|
||||||
- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump.
|
|
||||||
- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar.
|
|
||||||
- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective."
|
|
||||||
- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread.
|
|
||||||
|
|
||||||
### IV.5 Vendor stats per player (the "eventually")
|
|
||||||
|
|
||||||
Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale.
|
|
||||||
|
|
||||||
### IV.6 Updated capability map (supersedes III.5)
|
|
||||||
|
|
||||||
| Capability | Mechanism | Core touch? | Cadence |
|
|
||||||
|-----------|-----------|:-----------:|---------|
|
|
||||||
| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event |
|
|
||||||
| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change |
|
|
||||||
| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request |
|
|
||||||
| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event |
|
|
||||||
| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event |
|
|
||||||
| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request |
|
|
||||||
| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event |
|
|
||||||
| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 30–60 s sweep |
|
|
||||||
| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound |
|
|
||||||
| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` |
|
|
||||||
|
|
||||||
**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown
|
|
||||||
|
|
||||||
**Compilation model (this is a *modern* ServUO, not the old CodeDom one).**
|
|
||||||
`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs:
|
|
||||||
|
|
||||||
```
|
|
||||||
dotnet build "Scripts/Scripts.csproj" -c Release (or Debug)
|
|
||||||
```
|
|
||||||
|
|
||||||
then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`).
|
|
||||||
|
|
||||||
- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive.
|
|
||||||
- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`).
|
|
||||||
|
|
||||||
**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):**
|
|
||||||
|
|
||||||
| Order | Mechanism | How you hook it |
|
|
||||||
|------:|-----------|-----------------|
|
|
||||||
| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type |
|
|
||||||
| 2 | `World.Load()` | (world state restored from `Saves/`) |
|
|
||||||
| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type |
|
|
||||||
| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` |
|
|
||||||
|
|
||||||
`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`.
|
|
||||||
|
|
||||||
**Shutdown.** Two clean hooks, both fire on the Core thread:
|
|
||||||
- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`.
|
|
||||||
- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote.
|
|
||||||
- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires.
|
|
||||||
|
|
||||||
**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. EventSink — available events, subscription, and frequency
|
|
||||||
|
|
||||||
**Subscription pattern:** `EventSink.<Name> += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke<Name>(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing).
|
|
||||||
|
|
||||||
### Events relevant to a state-export bridge
|
|
||||||
|
|
||||||
| Event | Fires when | Frequency | Notes for export |
|
|
||||||
|-------|-----------|-----------|------------------|
|
|
||||||
| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. |
|
|
||||||
| `Logout` | Player disconnect (in-world) | Low | Pair with Login. |
|
|
||||||
| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). |
|
|
||||||
| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). |
|
|
||||||
| `CreatureDeath` | NPC/creature dies | **Medium–High** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. |
|
|
||||||
| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. |
|
|
||||||
| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. |
|
|
||||||
| `AggressiveAction` | Combat aggression declared | Medium–High | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. |
|
|
||||||
| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. |
|
|
||||||
| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. |
|
|
||||||
| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. |
|
|
||||||
| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | Low–Medium | Naturally diff-shaped. |
|
|
||||||
| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. |
|
|
||||||
| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. |
|
|
||||||
| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. |
|
|
||||||
|
|
||||||
Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.).
|
|
||||||
|
|
||||||
> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference.
|
|
||||||
|
|
||||||
### ⚑ Gap flag — events with *no* clean EventSink hook
|
|
||||||
|
|
||||||
These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**:
|
|
||||||
|
|
||||||
- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.**
|
|
||||||
- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink.
|
|
||||||
- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink.
|
|
||||||
- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Timers — mechanism and which thread callbacks run on
|
|
||||||
|
|
||||||
**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*:
|
|
||||||
|
|
||||||
- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop.
|
|
||||||
- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`).
|
|
||||||
|
|
||||||
**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log).
|
|
||||||
|
|
||||||
Other properties worth knowing:
|
|
||||||
- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`).
|
|
||||||
- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers.
|
|
||||||
- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Object model & serialization — and a diff-friendly state shape
|
|
||||||
|
|
||||||
**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary<Serial, …>` (`World.cs:19-20`) — O(1) lookup by serial from the main thread.
|
|
||||||
|
|
||||||
**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`):
|
|
||||||
- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot.
|
|
||||||
- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save.
|
|
||||||
- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`).
|
|
||||||
|
|
||||||
**Recommendation for a diff-friendly representation (do NOT reuse the save system):**
|
|
||||||
The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// one line per change, main-thread produced, drained by background writer
|
|
||||||
{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" }
|
|
||||||
{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" }
|
|
||||||
{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`.
|
|
||||||
- Keyed by `Serial` so the external service maintains its own mirror and applies deltas.
|
|
||||||
- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary.
|
|
||||||
- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Thread-safety rules & marshaling onto the main thread
|
|
||||||
|
|
||||||
**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard.
|
|
||||||
|
|
||||||
**What *is* safe from a non-main thread:**
|
|
||||||
- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`).
|
|
||||||
- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive.
|
|
||||||
- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue<NetState>` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`).
|
|
||||||
|
|
||||||
**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest):
|
|
||||||
|
|
||||||
- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`).
|
|
||||||
- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates.
|
|
||||||
|
|
||||||
**Pitfalls specific to this codebase:**
|
|
||||||
1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile.
|
|
||||||
2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6).
|
|
||||||
3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike.
|
|
||||||
4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue.
|
|
||||||
5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O
|
|
||||||
|
|
||||||
> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick.
|
|
||||||
|
|
||||||
Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that.
|
|
||||||
|
|
||||||
**Outbound (fire-and-forget writer) — the important one:**
|
|
||||||
- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation.
|
|
||||||
- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe.
|
|
||||||
- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries.
|
|
||||||
- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data.
|
|
||||||
- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records.
|
|
||||||
|
|
||||||
**Inbound (command listener):**
|
|
||||||
- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API.
|
|
||||||
- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads.
|
|
||||||
- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread.
|
|
||||||
|
|
||||||
**Pitfalls:**
|
|
||||||
- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads.
|
|
||||||
- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`.
|
|
||||||
- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Flags against the bridge architecture
|
|
||||||
|
|
||||||
> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background).
|
|
||||||
|
|
||||||
1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design.
|
|
||||||
2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional.
|
|
||||||
3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`.
|
|
||||||
4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but:
|
|
||||||
5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes.
|
|
||||||
6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots.
|
|
||||||
7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown.
|
|
||||||
8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path.
|
|
||||||
9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A — Drop-in empirical probe (run this yourself)
|
|
||||||
|
|
||||||
Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using Server;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
public static class BridgeThreadProbe
|
|
||||||
{
|
|
||||||
private static void Log(string where)
|
|
||||||
{
|
|
||||||
var t = Thread.CurrentThread;
|
|
||||||
Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"",
|
|
||||||
where, t.ManagedThreadId, t.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Initialize()
|
|
||||||
{
|
|
||||||
Log("Initialize"); // expect: Core Thread
|
|
||||||
|
|
||||||
EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread
|
|
||||||
EventSink.Login += e => Log("Login (client)"); // needs a client login
|
|
||||||
|
|
||||||
// Timer tick — proves callbacks run on the main thread, not the Timer Thread.
|
|
||||||
Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread
|
|
||||||
|
|
||||||
// Cross-thread marshal test: schedule from a raw background thread,
|
|
||||||
// confirm the callback still lands on Core Thread.
|
|
||||||
new Thread(() =>
|
|
||||||
{
|
|
||||||
Log("raw bg thread"); // expect: some worker id, NOT Core Thread
|
|
||||||
Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main"));
|
|
||||||
}).Start();
|
|
||||||
|
|
||||||
// Core.Slice runs every main-loop iteration; log once then detach.
|
|
||||||
Slice one = null;
|
|
||||||
one = () => { Log("Core.Slice"); Core.Slice -= one; };
|
|
||||||
Core.Slice += one; // expect: Core Thread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key source references
|
|
||||||
|
|
||||||
| Topic | File:line |
|
|
||||||
|-------|-----------|
|
|
||||||
| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` |
|
|
||||||
| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` |
|
|
||||||
| `Core.Set` wake main loop | `Server/Main.cs:322-327` |
|
|
||||||
| Shutdown / Crashed hooks | `Server/Main.cs:198,313` |
|
|
||||||
| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` |
|
|
||||||
| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` |
|
|
||||||
| EventSink event declarations | `Server/EventSink.cs:1692-1784` |
|
|
||||||
| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` |
|
|
||||||
| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` |
|
|
||||||
| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` |
|
|
||||||
| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` |
|
|
||||||
| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` |
|
|
||||||
| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` |
|
|
||||||
| Serial identity | `Server/Serial.cs:7-33` |
|
|
||||||
| Serialization API | `Server/Serialization.cs:17+` |
|
|
||||||
| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` |
|
|
||||||
| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` |
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Shard prerequisites
|
|
||||||
|
|
||||||
Repairs the target shard (`C:\Users\colby\Desktop\servuo`, ServUO 57.4) required before the bridge could load. These are **deletions and edits of existing files**, so they cannot be expressed as an overlay copy. They are recorded here, and where practical as diffs under `patches/`.
|
|
||||||
|
|
||||||
Applied 2026-07-10. Backups on the Desktop: `servuo_saves_backup_2026-07-10_032608`, `servuo_bin_backup_2026-07-10_032608`, `servuo_removed_files_2026-07-10`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The symptom
|
|
||||||
|
|
||||||
`Scripts.dll` had not been rebuilt since **2026-05-30 17:01**. Every script change after that — including all of `Scripts/Custom/Named/`, `MyStats.cs`, and `SearchAdd.cs` — had never executed.
|
|
||||||
|
|
||||||
`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) shells out to `dotnet build`, prints the output, ignores the exit code, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. A failing script build is invisible: the stale DLL simply reloads. The retry loop at `Main.cs:525` never trips.
|
|
||||||
|
|
||||||
Four independent breakages, all introduced between 17:14 and 21:55 on 2026-05-30.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Stray `Server/Gumps/Gumps.cs`
|
|
||||||
|
|
||||||
A **byte-identical copy** of `Scripts/Services/Pet Training/Gumps.cs` (75,468 bytes), sitting in the Server project. It declares `namespace Server.Mobiles` and extends `BaseGump`, referencing `BaseCreature`, `PlayerMobile`, `TrainingPoint` — all defined in Scripts. Server cannot reference Scripts, so `Server.csproj` failed with 35 errors.
|
|
||||||
|
|
||||||
**Action:** deleted. The canonical copy under `Scripts/Services/Pet Training/` was edited 10 minutes later and is the one that matters.
|
|
||||||
|
|
||||||
## 2. Eleven duplicate creature classes
|
|
||||||
|
|
||||||
`Scripts/Custom/{Named,Legendary}/` redefined classes already present in `Scripts/Mobiles/Normal/`, producing `CS0111` / `CS0579`.
|
|
||||||
|
|
||||||
**Named** — `Eowmu`, `SkeletalCat`, `Windrunner`. The stock files each define **two** types: the mount *and* an `ICreatureStatuette` item (`EowmuStatue`, …) that `Scripts/Services/UltimaStore/UltimaStore.cs` references. Deleting the stock files outright would have re-broken the build.
|
|
||||||
|
|
||||||
**Action:** removed only the duplicate mount class from each stock file; kept the statues.
|
|
||||||
|
|
||||||
**Legendary** — `FireSteed`, `Kirin`, `Nightmare`, `OsseinRam`, `Phoenix`, `PolarBear`, `ShadowWyrm`, `TsukiWolf`. Clean 1:1 pairs. All custom versions sit in `namespace Server.Mobiles`, so the serialized type name is unchanged, and each `Deserialize` guards on `version` and migrates from 0 (`ShadowWyrm`: `if (version >= 1)`; `FireSteed`: `if (version < 1)` skill-cap migration; `Kirin`: `if (version == 0)` AI fixup).
|
|
||||||
|
|
||||||
**Action:** deleted the eight stock files. Custom wins.
|
|
||||||
|
|
||||||
## 3. `PolarBear` — a base-class change, not a version bump
|
|
||||||
|
|
||||||
Custom `PolarBear : BaseMount`; stock `PolarBear : BaseCreature`. The saved world contained a bear serialized through the `BaseCreature` chain, so loading it as a `BaseMount` misaligned the stream. World load aborted at `Server.Mobiles.PolarBear` serial `0x00000412` with `Delete the object? (y/n)`.
|
|
||||||
|
|
||||||
**Changing a saved type's base class is not version-migratable.** The custom class also carried `[TypeAlias("Server.Mobiles.Polarbear")]`, which would have hijacked the same records.
|
|
||||||
|
|
||||||
**Action:** restored stock `PolarBear : BaseCreature`; renamed the custom mount to `LegendaryPolarBear` and dropped the `TypeAlias`. Stock scripts referencing `typeof(PolarBear)` (`TalismanSlayer`, `SpeedInfo`, `RoyalZooDonationBox`, `SummonCreature`, `PetTrainingHelper`) continue to resolve to the `BaseCreature`.
|
|
||||||
|
|
||||||
Note: `Scripts/Custom/Legendary/PolarBear.cs` was renamed to `LegendaryPolarBear.cs`.
|
|
||||||
|
|
||||||
## 4. `AnimalLore.cs` referenced a package that does not exist
|
|
||||||
|
|
||||||
`Scripts/Skills/AnimalLore.cs` had `using ShrinkSystem;` and two `IShrinkItem` branches. No `ShrinkSystem` namespace exists anywhere in the repo, and `IShrinkItem` appears nowhere in the stale `Scripts.dll` — **the code had never compiled or run.** (`Scripts/Misc/ShrinkTable.cs` is unrelated stock: `namespace Server`, class `ShrinkTable`.)
|
|
||||||
|
|
||||||
**Action:** removed the `using` and collapsed the shrink branches back to the `BaseCreature` path. This restores exactly the behavior the shard was already running.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After the repairs, `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` succeeded with 0 warnings, 0 errors. Rebuilding `ServUO.exe` and `Ultima.dll` from current source produced **byte-identical** binaries (same SHA-256), confirming the core was never stale in content — only `Scripts.dll` was.
|
|
||||||
|
|
||||||
With Phase 0 applied, a plain boot shows:
|
|
||||||
|
|
||||||
```
|
|
||||||
Core: Compiling scripts...
|
|
||||||
Build succeeded.
|
|
||||||
Core: Verified 6023 item and 1385 mobile types
|
|
||||||
World: Loading...
|
|
||||||
...done (206208 items, 42771 mobiles, 0 customs)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Unrelated, still open
|
|
||||||
|
|
||||||
`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing.
|
|
||||||
@@ -14,7 +14,7 @@ Port=7788
|
|||||||
QueueCap=10000
|
QueueCap=10000
|
||||||
|
|
||||||
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
|
# Sweep intervals, seconds. Measured on a 150-character shard: a vitals sweep costs
|
||||||
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See docs/PLAN.md §1.
|
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||||
StatSweepSeconds=30
|
StatSweepSeconds=30
|
||||||
DecaySweepSeconds=60
|
DecaySweepSeconds=60
|
||||||
EconomySweepSeconds=300
|
EconomySweepSeconds=300
|
||||||
@@ -29,7 +29,7 @@ ChampSweepSeconds=10
|
|||||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||||
PageSweepSeconds=5
|
PageSweepSeconds=5
|
||||||
|
|
||||||
# Guild roster poll (docs/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
|
# Guild roster poll (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Guilds expose only EventSink.JoinGuild, so
|
||||||
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
|
# create/disband/leave/leader/alliance changes are found by diffing BaseGuild.List on this
|
||||||
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
||||||
GuildSweepSeconds=60
|
GuildSweepSeconds=60
|
||||||
@@ -85,7 +85,7 @@ AdminReasonMaxLength=400
|
|||||||
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
|
||||||
AdminBanMaxDurationSec=31536000
|
AdminBanMaxDurationSec=31536000
|
||||||
|
|
||||||
# Account provisioning (docs/PROTOCOL_2.md Part A). Which side may mint game accounts:
|
# Account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A). Which side may mint game accounts:
|
||||||
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
|
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
|
||||||
# (else an in-game login of any new name still mints an account).
|
# (else an in-game login of any new name still mints an account).
|
||||||
# game — the game server is the authority; website account.create is refused.
|
# game — the game server is the authority; website account.create is refused.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// and replies link.ok. The tag persists to accounts.xml across restarts.
|
/// and replies link.ok. The tag persists to accounts.xml across restarts.
|
||||||
///
|
///
|
||||||
/// The code table and the account write both live on the Core thread. The websiteUserId in
|
/// The code table and the account write both live on the Core thread. The websiteUserId in
|
||||||
/// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the
|
/// link.confirm is trusted only because the socket is loopback-only (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2); if the
|
||||||
/// sidecar ever moves off-host, gate it behind a shared secret.
|
/// sidecar ever moves off-host, gate it behind a shared secret.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BridgeAccountLink
|
public static class BridgeAccountLink
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Server.Misc;
|
|||||||
namespace Server.Custom.Bridge
|
namespace Server.Custom.Bridge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The account provisioning plane (docs/PROTOCOL_2.md Part A): website-driven account
|
/// The account provisioning plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A): website-driven account
|
||||||
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
|
||||||
/// is unchanged.
|
/// is unchanged.
|
||||||
///
|
///
|
||||||
@@ -18,7 +18,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
|
||||||
/// Timer.DelayCall first), so they touch accounts freely.
|
/// Timer.DelayCall first), so they touch accounts freely.
|
||||||
///
|
///
|
||||||
/// Trust model matches the admin plane (docs/ADMIN_CONTROLS.md §5): authorization lives on
|
/// Trust model matches the admin plane (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization lives on
|
||||||
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
|
||||||
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
|
||||||
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
|
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
|
||||||
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
|
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
|
||||||
///
|
///
|
||||||
/// Trust model (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
/// Trust model (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
||||||
/// these commands are gated there behind admin/moderator roles. The shard trusts the
|
/// these commands are gated there behind admin/moderator roles. The shard trusts the
|
||||||
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
|
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
|
||||||
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
|
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
|
||||||
@@ -248,7 +248,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
|
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
|
||||||
/// connected dashboard so the website's moderation log stays complete regardless of which
|
/// connected dashboard so the website's moderation log stays complete regardless of which
|
||||||
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
|
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
|
||||||
/// BridgeEvents; see docs/ADMIN_CONTROLS.md §5.5.
|
/// BridgeEvents; see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
|
||||||
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
|
||||||
/// the operator pairs with this (docs/PROTOCOL_2.md §2).
|
/// the operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum SignupMode
|
public enum SignupMode
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,7 @@ namespace Server.Custom.Bridge
|
|||||||
public static int TownCrierMaxActive { get; private set; }
|
public static int TownCrierMaxActive { get; private set; }
|
||||||
public static int TownCrierMaxDurationSec { get; private set; }
|
public static int TownCrierMaxDurationSec { get; private set; }
|
||||||
|
|
||||||
// Town Cryer news gump (docs/PROTOCOL_2.md §16).
|
// Town Cryer news gump (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16).
|
||||||
public static int NewsMaxTitleLength { get; private set; }
|
public static int NewsMaxTitleLength { get; private set; }
|
||||||
public static int NewsMaxBodyLength { get; private set; }
|
public static int NewsMaxBodyLength { get; private set; }
|
||||||
public static int NewsMaxExternal { get; private set; }
|
public static int NewsMaxExternal { get; private set; }
|
||||||
@@ -55,7 +55,7 @@ namespace Server.Custom.Bridge
|
|||||||
public static int AdminReasonMaxLength { get; private set; }
|
public static int AdminReasonMaxLength { get; private set; }
|
||||||
public static int AdminBanMaxDurationSec { get; private set; }
|
public static int AdminBanMaxDurationSec { get; private set; }
|
||||||
|
|
||||||
// ---- account provisioning (docs/PROTOCOL_2.md Part A) ----
|
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
||||||
public static SignupMode Signup { get; private set; }
|
public static SignupMode Signup { get; private set; }
|
||||||
public static bool AccountCreateEnabled { get; private set; }
|
public static bool AccountCreateEnabled { get; private set; }
|
||||||
public static bool RequireIpForCreate { get; private set; }
|
public static bool RequireIpForCreate { get; private set; }
|
||||||
@@ -89,7 +89,7 @@ namespace Server.Custom.Bridge
|
|||||||
if (ChampSweepSeconds < 1)
|
if (ChampSweepSeconds < 1)
|
||||||
ChampSweepSeconds = 1;
|
ChampSweepSeconds = 1;
|
||||||
|
|
||||||
// Social/political sweeps (docs/PROTOCOL_2.md Part B). Both change slowly, so the
|
// Social/political sweeps (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part B). Both change slowly, so the
|
||||||
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
// defaults are unhurried; the pass is a handful of field reads over a small set.
|
||||||
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
|
||||||
if (GuildSweepSeconds < 1)
|
if (GuildSweepSeconds < 1)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Server.Engines.CityLoyalty;
|
|||||||
namespace Server.Custom.Bridge
|
namespace Server.Custom.Bridge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The town-governor stream (docs/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
|
/// The town-governor stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a
|
||||||
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
|
/// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of
|
||||||
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
|
/// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
|
||||||
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
|
/// an EventSink, so — like <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — the set
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Server.Multis;
|
|||||||
namespace Server.Custom.Bridge
|
namespace Server.Custom.Bridge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The housing registry (docs/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
|
/// The housing registry (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay
|
||||||
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
|
/// *transitions*; this is the complementary *board*: one row per house with owner, location,
|
||||||
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
|
/// region, co-owners, value, and current decay level, so the website can render an owner→houses
|
||||||
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
|
/// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace Server.Custom.Bridge
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
|
/// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
|
||||||
/// every emitted event, and the measured budget in docs/PLAN.md assumes this cost, not a
|
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
|
||||||
/// reflection serializer's.
|
/// reflection serializer's.
|
||||||
///
|
///
|
||||||
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace Server.Custom.Bridge
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Website news articles pushed into the modern Town Cryer News gump
|
/// Website news articles pushed into the modern Town Cryer News gump
|
||||||
/// (docs/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
|
/// (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier
|
||||||
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
|
/// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML),
|
||||||
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
|
/// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries,
|
||||||
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
|
/// which the stock news gumps already render (they branch on TextDefinition.Number, so string
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using Server.Mobiles;
|
|||||||
namespace Server.Custom.Bridge
|
namespace Server.Custom.Bridge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The presence stream (docs/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
|
/// The presence stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts:
|
||||||
///
|
///
|
||||||
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
|
/// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted
|
||||||
/// on a sweep but only when it changes, so the site has a live "N online"
|
/// on a sweep but only when it changes, so the site has a live "N online"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace Server.Custom.Bridge
|
|||||||
///
|
///
|
||||||
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
||||||
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
||||||
/// a sweep. See docs/PLAN.md §1.
|
/// a sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BridgeProfile
|
public static class BridgeProfile
|
||||||
{
|
{
|
||||||
@@ -83,7 +83,7 @@ namespace Server.Custom.Bridge
|
|||||||
}
|
}
|
||||||
sb.Append(']');
|
sb.Append(']');
|
||||||
|
|
||||||
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
|
// worn equipment only — not the backpack/bank (see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §IV.4)
|
||||||
sb.Append(",\"equipment\":[");
|
sb.Append(",\"equipment\":[");
|
||||||
first = true;
|
first = true;
|
||||||
foreach (var item in m.Items)
|
foreach (var item in m.Items)
|
||||||
@@ -104,7 +104,7 @@ namespace Server.Custom.Bridge
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The titles a character holds (docs/PROTOCOL_2.md §10.3). `selected` is the index into
|
/// The titles a character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.3). `selected` is the index into
|
||||||
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
|
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
|
||||||
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
|
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
|
||||||
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
|
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Server.Guilds;
|
|||||||
namespace Server.Custom.Bridge
|
namespace Server.Custom.Bridge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The guild stream (docs/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
/// The guild stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
||||||
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
||||||
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
||||||
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The three polled streams, for state that has no EventSink: player vitals, house decay,
|
/// The three polled streams, for state that has no EventSink: player vitals, house decay,
|
||||||
/// and money supply. All three run on the Core thread via repeating Timers, and the
|
/// and money supply. All three run on the Core thread via repeating Timers, and the
|
||||||
/// measured cost (docs/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
/// measured cost (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
||||||
/// three is well under a millisecond.
|
/// three is well under a millisecond.
|
||||||
///
|
///
|
||||||
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
|
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Server.Custom.Bridge
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
|
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
|
||||||
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
|
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
|
||||||
/// came from the website or a staff member in the game client. See docs/ADMIN_CONTROLS.md §5.5.
|
/// came from the website or a staff member in the game client. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||||
///
|
///
|
||||||
/// Two sources, mirroring how the shard records each:
|
/// Two sources, mirroring how the shard records each:
|
||||||
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ git apply patches/<name>.patch
|
|||||||
|
|
||||||
## Phase 7 — player-vendor sale (a coupled unit)
|
## Phase 7 — player-vendor sale (a coupled unit)
|
||||||
|
|
||||||
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See `docs/PLAN.md` §6.
|
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.
|
||||||
|
|
||||||
This is the one non-drop-in piece. Apply all three together:
|
This is the one non-drop-in piece. Apply all three together:
|
||||||
|
|
||||||
@@ -56,4 +56,4 @@ Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Sc
|
|||||||
|
|
||||||
## Note on shard repairs
|
## Note on shard repairs
|
||||||
|
|
||||||
The deletions and edits described in `docs/SHARD_PREREQS.md` are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
The deletions and edits described in [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ website ──WS (live feed) / REST (queries)──► sidecar ──loopback
|
|||||||
(this) newline-JSON, bidirectional
|
(this) newline-JSON, bidirectional
|
||||||
```
|
```
|
||||||
|
|
||||||
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See `../docs/PLAN.md` §2.
|
The sidecar is the TCP **listener**; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §2.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -106,4 +106,4 @@ A shard `*.error` reply maps to HTTP 404 (unknown/not-found) or 400 (bad request
|
|||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in `../docs/PLAN.md` (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
Every line is one JSON object with `t` (epoch ms) and `kind`. The shard→sidecar events and sidecar→shard commands are catalogued in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: `char.request`, `account.roster`, `vendor.snapshot`, `link.confirm`, `towncrier.add`/`remove`, `ping`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
//! The shard is the TCP *client*: it dials out to us. So the sidecar owns the listener, and the
|
||||||
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
//! shard's outbound socket is the only thing that ever connects. This is the whole reason the game
|
||||||
//! is never directly reachable from the website — it exposes no port. See docs/PLAN.md §2.
|
//! is never directly reachable from the website — it exposes no port. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2.
|
||||||
//!
|
//!
|
||||||
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
|
||||||
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
|||||||
.route("/economy", get(economy))
|
.route("/economy", get(economy))
|
||||||
.route("/champs", get(champs))
|
.route("/champs", get(champs))
|
||||||
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
|
// World-state boards (Protocol 2.0), served from the store so they answer without the shard
|
||||||
// and survive an outage with the last-known snapshot (docs/PROTOCOL_2.md §12.2).
|
// and survive an outage with the last-known snapshot (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §12.2).
|
||||||
.route("/guilds", get(guilds))
|
.route("/guilds", get(guilds))
|
||||||
.route("/governors", get(governors))
|
.route("/governors", get(governors))
|
||||||
.route("/online", get(online))
|
.route("/online", get(online))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
|
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
|
||||||
|
|
||||||
These two scripts produced the measured budget in `docs/PLAN.md` §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
||||||
|
|
||||||
| File | Server path when testing | What |
|
| File | Server path when testing | What |
|
||||||
|------|--------------------------|------|
|
|------|--------------------------|------|
|
||||||
|
|||||||
Reference in New Issue
Block a user