diff --git a/README.md b/README.md
index 4fde35e..1abb063 100644
--- a/README.md
+++ b/README.md
@@ -1,98 +1,60 @@
-# uo-link
+# uo-link — Rust sidecar
-ServUO ⇄ Rust sidecar bridge. The shard emits newline-delimited JSON over a loopback TCP socket; the sidecar owns the WebSocket the website consumes.
+The **Rust sidecar** half of the Runic Gateway bridge. The ServUO shard dials out to this sidecar
+over a loopback TCP socket (newline-delimited JSON); the sidecar owns the WebSocket + REST API the
+website consumes, along with auth, buffering, and fan-out.
```
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
- (Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
+ (RunicGateway/servuo-plugins) >>> THIS REPO <<<
```
-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 and exposes no port of its own — the sidecar is the only
+network-facing component, which is what keeps the game unreachable from the internet.
-## Documentation
+## Related repos
-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.
+| Repo | What |
+|------|------|
+| **this** — `RunicGateway/link` | The Rust sidecar (`sidecar/`). |
+| [RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins) | The **C# ServUO plugin** — the shard side of the bridge (`overlay/`, `patches/`, `deploy.ps1`, test scaffolding). |
+| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation — design docs, protocol spec, integration guide, research. |
## Layout
| Path | What |
|------|------|
-| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
-| `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`. |
-| `tools/` | Never deployed. Test scaffolding and anything else that must not reach a server. |
-| [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. |
-| [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) | Implementation plan, measured performance budget, and the full data catalog. |
-| [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. |
-| [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. |
+| `sidecar/` | The Rust sidecar crate — terminates the loopback link to the shard, exposes WS + REST to the website. See [`sidecar/README.md`](sidecar/README.md). |
+| `.gitea/workflows/release.yml` | Builds + releases the sidecar binary (Linux + Windows) on every merge to `main`. |
-Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
+## Build & run
-## Deploy
+The sidecar is a standard cargo crate:
-```powershell
-.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
-.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
+```bash
+cd sidecar
+cargo build --release # binary at target/release/uo-link-sidecar
+cp sidecar.toml.example sidecar.toml # then edit
+cargo run --release
```
-## Status
+`.gitea/workflows/release.yml` cross-compiles Linux + Windows binaries and cuts a Gitea release on
+every merge to `main` (conventional-commit versioning). See [`sidecar/README.md`](sidecar/README.md)
+for configuration and the wire protocol.
-| Phase | State |
-|------:|-------|
-| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
-| 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 [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §12** |
-| 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 [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §14** |
-| 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 [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §16** |
-| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §17** |
+## Deployment & compatibility
-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.
+The plugin ([RunicGateway/servuo-plugins](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins))
+and this sidecar are deployed **together** but built **independently**:
-Cheat-detection signals are not a separate phase — they are folded into the streams above: `cheat.fastwalk`, `audit.set`, `audit.command`, and `vendor.sale` (buyer + owner for laundering detection).
+- The **plugin** is deployed as source into the ServUO server root and compiled by ServUO at boot —
+ no build artifact, no CI build.
+- The **sidecar** is a standalone Rust binary released from this repo.
-## Phase 0 — what it fixes
-
-`ScriptCompiler.Compile()` runs `dotnet build Scripts/Scripts.csproj -c Release`, prints the output, and **never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Because that build passed no `Platform`, MSBuild defaulted to `AnyCPU`, and `Scripts.csproj` gated both `OutputPath` and `DefineConstants` on `Configuration|Platform == Release|x64`. So:
-
-- the DLL landed in `Scripts/bin/Release/` while the core loads `Scripts.dll` from the base directory, and
-- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
-
-Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
-
-`Server.csproj` is deliberately left alone: nothing under `Server/` uses those symbols, and giving it `OutputPath=..\` would make the boot-time build try to overwrite the running `ServUO.exe`.
-
-## The plugin (Phase 1)
-
-`overlay/Scripts/Custom/Bridge/`:
-
-| File | Responsibility |
-|------|----------------|
-| `BridgeConfig.cs` | Reads `Config/Bridge.cfg` in `Configure()`, before `World.Load`. |
-| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
-| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
-| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
-| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
-| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
-| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
-| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
-| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
-| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. |
-
-`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
-
-## Testing
-
-`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
-
-```powershell
-.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
-```
-
-`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
-
-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 [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. See `tools/scaffolding/README.md`.
+The only coupling is the **loopback JSON protocol** (the shard dials `127.0.0.1`). Compatibility is a
+protocol concern, not a build-order one — keep the event/command catalog in sync across the two
+repos. Canonical spec:
+[PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §5/§7 and
+[INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md).
+Because a wedged or absent sidecar cannot stall the shard, either side can be deployed or restarted
+independently.
diff --git a/deploy.ps1 b/deploy.ps1
deleted file mode 100644
index 578e6ab..0000000
--- a/deploy.ps1
+++ /dev/null
@@ -1,75 +0,0 @@
-<#
-.SYNOPSIS
- Copies overlay/ into a ServUO server root.
-
-.DESCRIPTION
- overlay/ mirrors the server root exactly, so deployment is a straight file copy.
- Nothing is deleted from the server; this only adds or overwrites.
-
- Run with -Verify first. It reports what would change and touches nothing.
-
-.EXAMPLE
- .\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify
- .\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo
-#>
-[CmdletBinding()]
-param(
- [Parameter(Mandatory = $true)]
- [string] $ServerPath,
-
- [switch] $Verify
-)
-
-$ErrorActionPreference = 'Stop'
-
-$overlay = Join-Path $PSScriptRoot 'overlay'
-
-if (-not (Test-Path $overlay)) { throw "overlay/ not found next to deploy.ps1" }
-if (-not (Test-Path $ServerPath)) { throw "server path not found: $ServerPath" }
-
-# ServUO.exe holds a lock on Scripts.dll and writes Saves/ on exit. Never deploy under it.
-if (Get-Process -Name ServUO -ErrorAction SilentlyContinue) {
- throw "ServUO is running. Stop it before deploying."
-}
-
-function Get-Sha([string] $path) {
- if (-not (Test-Path $path)) { return $null }
- return (Get-FileHash $path -Algorithm SHA256).Hash
-}
-
-$added = 0; $changed = 0; $same = 0
-
-Get-ChildItem $overlay -Recurse -File | ForEach-Object {
- $rel = $_.FullName.Substring($overlay.Length + 1)
- $dst = Join-Path $ServerPath $rel
-
- $srcHash = Get-Sha $_.FullName
- $dstHash = Get-Sha $dst
-
- if ($null -eq $dstHash) {
- $state = 'ADD '; $added++
- } elseif ($srcHash -ne $dstHash) {
- $state = 'CHANGE '; $changed++
- } else {
- $state = 'same '; $same++
- }
-
- if ($state -ne 'same ') {
- Write-Output "$state $rel"
- }
-
- if (-not $Verify -and $state -ne 'same ') {
- $parent = Split-Path $dst -Parent
- if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
- Copy-Item $_.FullName -Destination $dst -Force
- }
-}
-
-Write-Output ""
-if ($Verify) {
- Write-Output "VERIFY only. add=$added change=$changed unchanged=$same (nothing written)"
-} else {
- Write-Output "deployed. add=$added change=$changed unchanged=$same"
- Write-Output ""
- Write-Output "Scripts.csproj changed => next boot rebuilds Scripts.dll (Compiler.cfg Dynamic=True)."
-}
diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
deleted file mode 100644
index ef09957..0000000
--- a/overlay/Config/Bridge.cfg
+++ /dev/null
@@ -1,116 +0,0 @@
-
-# uo-link bridge settings.
-#
-# Key scope is the filename: Bridge.cfg + StatSweepSeconds => "Bridge.StatSweepSeconds".
-# Read in Configure(), which runs before World.Load.
-
-# Loopback only. The socket being local is the trust boundary for inbound commands;
-# if the sidecar ever moves off-host, add a shared secret first.
-Host=127.0.0.1
-Port=7788
-
-# Outbound queue cap. On overflow the plugin drops oldest and counts the drops,
-# because a stalled sidecar must never OOM the shard.
-QueueCap=10000
-
-# 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 https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
-StatSweepSeconds=30
-DecaySweepSeconds=60
-EconomySweepSeconds=300
-
-# Champion-spawn board poll. ChampionSpawn has no EventSink, so every spawn is diffed on
-# this interval to emit champ.update on any status/level/kills/boss change. The world holds
-# only a handful of spawns, so the pass is trivial; 5-10s is well within site tolerance.
-ChampSweepSeconds=10
-
-# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
-# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
-# support queue; the full open queue is also available on demand via pages.snapshot.
-PageSweepSeconds=5
-
-# 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
-# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
-GuildSweepSeconds=60
-
-# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
-# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
-# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
-CitySweepSeconds=300
-
-# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
-# interval and emitted as presence.online only when it changes. Region transitions come
-# through separately in real time as region.enter (EventSink.OnEnterRegion).
-PresenceSweepSeconds=30
-
-# Housing registry poll. Every house is diffed on this interval to emit house.update /
-# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
-HousingSweepSeconds=300
-
-# Shown to a player when they run [link. The website page where they enter the code.
-LinkUrl=https://yoursite/link
-
-# Town-crier news pushed from the website. Caps are defense in depth on top of the
-# loopback trust boundary: a buggy or compromised sidecar still cannot flood the criers.
-TownCrierMaxLines=6
-TownCrierMaxLineLength=200
-TownCrierMaxActive=20
-TownCrierMaxDurationSec=86400
-
-# Town Cryer news gump. Website articles (news.add) become entries in the modern Town
-# Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines
-# above. The article title is also proclaimed by the criers (announce defaults on). Caps
-# are defense in depth on top of the loopback trust boundary.
-NewsMaxTitleLength=100
-NewsMaxBodyLength=2000
-NewsMaxExternal=20
-NewsAnnounceDurationSec=300
-
-# Admin write plane (staff moderation from the website). OFF by default: the whole
-# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
-# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
-# loopback socket and applies a hard floor below.
-AdminWriteEnabled=false
-
-# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
-# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
-# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
-# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
-AdminAccessFloor=CoOwner
-
-# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
-AdminBroadcastMaxLength=300
-AdminReasonMaxLength=400
-# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
-AdminBanMaxDurationSec=31536000
-
-# 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
-# (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.
-# hybrid — either side may create (the default).
-# The bridge governs only the account.create verb; the in-game first-login auto-create is
-# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
-# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
-# (the safest — no website creation).
-SignupMode=hybrid
-
-# Master switch for the account.create verb. Absent, it follows the mode (on unless
-# SignupMode=game). Set explicitly to force it on or off regardless of mode.
-AccountCreateEnabled=true
-
-# Fail closed if account.create omits a usable browser IP. The per-IP cap
-# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
-# than waved through. Turn off only for a deployment that deliberately does not cap website
-# signups by IP (MaxAccountsPerIP still applies in-game either way).
-RequireIpForCreate=true
-
-# Length caps on a website-supplied username / password, checked before the account is made.
-AccountNameMaxLength=16
-AccountPasswordMaxLength=30
-
-# The test scaffolding in tools/scaffolding/ reads its own flags from this file
-# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
-# Config.Get returns the default of false when a key is missing, so a deployed
-# server never runs the scaffolding even if its .cs files are present.
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
deleted file mode 100644
index 24f98ed..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
+++ /dev/null
@@ -1,298 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Accounting;
-using Server.Commands;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Ties a game account to a website account.
- ///
- /// Flow:
- /// 1. In game, the player runs [link. The shard mints a short, one-time, expiring code,
- /// holds it in memory keyed to their account, and emits link.request to the sidecar.
- /// 2. The player enters that code on the website. The website tells the sidecar, which
- /// sends link.confirm inbound.
- /// 3. The shard validates the code, writes Account tag "WebsiteUserId", drops the code,
- /// 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
- /// 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.
- ///
- public static class BridgeAccountLink
- {
- private const string Tag = "WebsiteUserId";
-
- // Unambiguous alphabet: no O/0, I/1, so a player reading a code aloud can't get it wrong.
- private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
- private const int CodeLength = 6;
-
- private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
- private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
-
- private sealed class Pending
- {
- public string Account;
- public DateTime Expires;
- }
-
- // code -> pending link. Core-thread only.
- private static readonly Dictionary _codes =
- new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- // account -> last [link time, to rate-limit code spam.
- private static readonly Dictionary _lastRequest =
- new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
- CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
- BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
-
- // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
- Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), PurgeExpired);
- }
-
- /// Reads the linked website id for an account, or null. Used to enrich events.
- public static string WebIdFor(Account acct)
- {
- if (acct == null)
- return null;
-
- return acct.GetTag(Tag);
- }
-
- // ---- [link ----
-
- [Usage("link")]
- [Description("Links this game account to your website account via a one-time code.")]
- private static void OnLinkCommand(CommandEventArgs e)
- {
- RequestLink(e.Mobile);
- }
-
- ///
- /// Mints a one-time code for the mobile's account and emits link.request. This is the
- /// body of the [link command, exposed so it can be driven in tests without a client.
- ///
- public static void RequestLink(Mobile m)
- {
- if (m == null)
- return;
-
- var acct = m.Account as Account;
-
- if (acct == null)
- {
- m.SendMessage("Bridge: no account on this character.");
- return;
- }
-
- var existing = acct.GetTag(Tag);
- if (existing != null)
- {
- m.SendMessage("Your account is already linked to website user {0}.", existing);
- return;
- }
-
- DateTime last;
- if (_lastRequest.TryGetValue(acct.Username, out last) && DateTime.UtcNow - last < RequestCooldown)
- {
- m.SendMessage("Please wait a moment before requesting another link code.");
- return;
- }
-
- // One outstanding code per account: drop any prior code so only the newest works.
- DropCodesFor(acct.Username);
-
- var code = MintCode();
- _codes[code] = new Pending { Account = acct.Username, Expires = DateTime.UtcNow + CodeTtl };
- _lastRequest[acct.Username] = DateTime.UtcNow;
-
- BridgeLink.Emit(BridgeJson.Begin("link.request")
- .Str("code", code)
- .Str("account", acct.Username)
- .Str("char", m.Name)
- .Num("ttlSec", (long)CodeTtl.TotalSeconds)
- .End());
-
- var url = BridgeConfig.LinkUrl;
- m.SendMessage(0x35, "Link code: {0}", code);
- m.SendMessage("Enter it at {0} within {1} minutes to link your account.",
- url, (int)CodeTtl.TotalMinutes);
- }
-
- // ---- [unlink ----
-
- [Usage("unlink")]
- [Description("Unlinks this game account from your website account.")]
- private static void OnUnlinkCommand(CommandEventArgs e)
- {
- Unlink(e.Mobile);
- }
-
- ///
- /// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
- /// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
- /// so it needs no access floor. After unlinking, [link works again.
- ///
- public static void Unlink(Mobile m)
- {
- if (m == null)
- return;
-
- var acct = m.Account as Account;
-
- if (acct == null)
- {
- m.SendMessage("Bridge: no account on this character.");
- return;
- }
-
- var existing = acct.GetTag(Tag);
- if (existing == null)
- {
- m.SendMessage("Your account is not linked to a website account.");
- return;
- }
-
- acct.RemoveTag(Tag);
- DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
-
- BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
- .Str("origin", "in-game")
- .Str("account", acct.Username)
- .Str("websiteUserId", existing)
- .Str("char", m.Name)
- .End());
-
- m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
- }
-
- // ---- inbound link.confirm ----
-
- private static void OnLinkConfirm(Dictionary o)
- {
- var code = BridgeJson.GetString(o, "code");
- var webId = BridgeJson.GetString(o, "websiteUserId");
-
- if (code == null || webId == null)
- {
- Reply("link.error", null, null, "malformed link.confirm");
- return;
- }
-
- Pending pending;
- if (!_codes.TryGetValue(code, out pending))
- {
- Reply("link.error", code, null, "unknown or expired code");
- return;
- }
-
- _codes.Remove(code);
-
- if (DateTime.UtcNow > pending.Expires)
- {
- Reply("link.error", code, pending.Account, "code expired");
- return;
- }
-
- var acct = Accounting.Accounts.GetAccount(pending.Account) as Account;
- if (acct == null)
- {
- Reply("link.error", code, pending.Account, "account no longer exists");
- return;
- }
-
- // Persisted to accounts.xml on the next world save.
- acct.SetTag(Tag, webId);
- DropCodesFor(pending.Account);
-
- Reply("link.ok", code, pending.Account, null, webId);
-
- NotifyOnline(acct, webId);
- }
-
- // ---- helpers ----
-
- private static void Reply(string kind, string code, string account, string reason, string webId = null)
- {
- var sb = BridgeJson.Begin(kind);
- if (code != null) sb.Str("code", code);
- if (account != null) sb.Str("account", account);
- if (webId != null) sb.Str("websiteUserId", webId);
- if (reason != null) sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
-
- private static void NotifyOnline(Account acct, string webId)
- {
- for (int i = 0; i < acct.Length; i++)
- {
- var m = acct[i];
- if (m != null && m.NetState != null)
- m.SendMessage(0x40, "Your account is now linked to website user {0}.", webId);
- }
- }
-
- private static string MintCode()
- {
- // Avoid a collision with an outstanding code, though at 32^6 it is astronomically rare.
- for (int attempt = 0; attempt < 8; attempt++)
- {
- var chars = new char[CodeLength];
- for (int i = 0; i < CodeLength; i++)
- chars[i] = Alphabet[Utility.Random(Alphabet.Length)];
-
- var code = new string(chars);
- if (!_codes.ContainsKey(code))
- return code;
- }
-
- // Fall back to a guaranteed-unique code.
- return "L" + DateTime.UtcNow.Ticks.ToString("X").Substring(0, CodeLength - 1);
- }
-
- private static void DropCodesFor(string account)
- {
- var doomed = new List();
-
- foreach (var kv in _codes)
- {
- if (String.Equals(kv.Value.Account, account, StringComparison.OrdinalIgnoreCase))
- doomed.Add(kv.Key);
- }
-
- foreach (var c in doomed)
- _codes.Remove(c);
- }
-
- private static void PurgeExpired()
- {
- try
- {
- var now = DateTime.UtcNow;
- var doomed = new List();
-
- foreach (var kv in _codes)
- {
- if (now > kv.Value.Expires)
- doomed.Add(kv.Key);
- }
-
- foreach (var c in doomed)
- _codes.Remove(c);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] link purge threw: {0}", ex.Message);
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
deleted file mode 100644
index ef414e2..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
+++ /dev/null
@@ -1,281 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Net;
-
-using Server.Accounting;
-using Server.Misc;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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
- /// is unchanged.
- ///
- /// account.create — mint a game account and link it to a website user in one step.
- /// account.unlink — sever the WebsiteUserId tie from the website side.
- ///
- /// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
- /// Timer.DelayCall first), so they touch accounts freely.
- ///
- /// 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 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 /
- /// AccountCreateEnabled.
- ///
- public static class BridgeAccounts
- {
- private const string Tag = "WebsiteUserId";
-
- // Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
- // in-game one (AccountHandler.cs). Kept local because that array is private.
- private static readonly char[] ForbiddenChars =
- {
- '<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
- };
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- BridgeBoot.RegisterHandler("account.create", OnCreate);
- BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
- }
-
- // ---- account.create ----
-
- ///
- /// Creates a game account and links it to the given website user. Refused unless the
- /// signup mode allows website creation. Enforces the same username/password character
- /// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
- /// process in any reply, audit, or log.
- ///
- private static void OnCreate(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var actor = BridgeJson.GetString(o, "actor");
- const string action = "create";
-
- if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
- {
- Err(reqId, action, "signups disabled for this mode");
- return;
- }
-
- if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
- {
- Err(reqId, action, "missing actor");
- return;
- }
-
- var account = BridgeJson.GetString(o, "account");
- var password = BridgeJson.GetString(o, "password");
- var webId = BridgeJson.GetString(o, "websiteUserId");
- var ipStr = BridgeJson.GetString(o, "ip");
-
- if (String.IsNullOrEmpty(account))
- {
- Err(reqId, action, "missing account");
- return;
- }
-
- if (String.IsNullOrEmpty(password))
- {
- Err(reqId, action, "missing password");
- return;
- }
-
- if (String.IsNullOrEmpty(webId))
- {
- Err(reqId, action, "missing websiteUserId");
- return;
- }
-
- if (account.Length > BridgeConfig.AccountNameMaxLength ||
- password.Length > BridgeConfig.AccountPasswordMaxLength)
- {
- Err(reqId, action, "username or password too long");
- return;
- }
-
- if (!IsSafeUsername(account) || !IsSafePassword(password))
- {
- Err(reqId, action, "invalid username/password");
- return;
- }
-
- // Collision: the only correct resolution of a website/in-game race for a name.
- if (Accounts.GetAccount(account) != null)
- {
- Err(reqId, action, "account already exists");
- return;
- }
-
- // Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
- // exempt in IPLimiter, so accepting it would silently bypass the cap.
- IPAddress ip;
- bool haveIp = TryParseIp(ipStr, out ip);
-
- if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
- {
- Err(reqId, action, "client ip required");
- return;
- }
-
- if (haveIp && !AccountHandler.CanCreate(ip))
- {
- Err(reqId, action, "ip account limit reached");
- return;
- }
-
- // Create + link. new Account self-registers (Accounts.Add) and hashes the password per
- // the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
- // in-game first-login does; the tag persists on the next world save.
- var acct = new Account(account, password);
-
- if (haveIp)
- acct.LogAccess(ip);
-
- acct.SetTag(Tag, webId);
-
- Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
- actor, account, webId, haveIp ? ip.ToString() : "-");
-
- BridgeLink.Emit(AuditBegin(action, actor, account)
- .Str("websiteUserId", webId)
- .End());
-
- var sb = BridgeJson.Begin("account.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- account.unlink ----
-
- ///
- /// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
- /// Owner floor keeps a protected staff account unreachable from the web.
- ///
- private static void OnUnlink(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var actor = BridgeJson.GetString(o, "actor");
- const string action = "unlink";
-
- if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
- {
- Err(reqId, action, "missing actor");
- return;
- }
-
- var acct = BridgeAdmin.ResolveTargetAccount(o);
- if (acct == null)
- {
- Err(reqId, action, "unknown or accountless target");
- return;
- }
-
- if (BridgeAdmin.Protected(acct))
- {
- Err(reqId, action, "target is protected staff; refused");
- return;
- }
-
- var existing = acct.GetTag(Tag);
- if (existing == null)
- {
- Err(reqId, action, "not linked");
- return;
- }
-
- acct.RemoveTag(Tag);
-
- Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
- actor, acct.Username, existing);
-
- BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
- .Str("websiteUserId", existing)
- .End());
-
- var sb = BridgeJson.Begin("account.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action).Str("account", acct.Username);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- helpers ----
-
- private static void Err(string reqId, string action, string reason)
- {
- var sb = BridgeJson.Begin("account.error");
- if (reqId != null) sb.Str("reqId", reqId);
- if (action != null) sb.Str("action", action);
- sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
-
- ///
- /// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
- /// admin.audit. Never carries the password.
- ///
- private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
- {
- return BridgeJson.Begin("account.audit")
- .Str("origin", "web")
- .Str("action", action)
- .Str("actor", "web:" + actor)
- .Str("target", target);
- }
-
- /// Mirrors the username safety rules in AccountHandler.CreateAccount.
- private static bool IsSafeUsername(string un)
- {
- if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
- return false;
-
- for (int i = 0; i < un.Length; i++)
- {
- char c = un[i];
- if (c < 0x20 || c >= 0x7F || IsForbidden(c))
- return false;
- }
-
- return true;
- }
-
- /// Mirrors the password safety rules in AccountHandler.CreateAccount.
- private static bool IsSafePassword(string pw)
- {
- for (int i = 0; i < pw.Length; i++)
- {
- char c = pw[i];
- if (c < 0x20 || c >= 0x7F)
- return false;
- }
-
- return true;
- }
-
- private static bool IsForbidden(char c)
- {
- for (int i = 0; i < ForbiddenChars.Length; i++)
- if (c == ForbiddenChars[i])
- return true;
-
- return false;
- }
-
- private static bool TryParseIp(string s, out IPAddress ip)
- {
- ip = null;
-
- if (String.IsNullOrEmpty(s))
- return false;
-
- return IPAddress.TryParse(s.Trim(), out ip);
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
deleted file mode 100644
index 28ea0ef..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
+++ /dev/null
@@ -1,364 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Accounting;
-using Server.Network;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// The staff write plane: moderation actions the website drives against the live shard.
- /// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
- ///
- /// 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.
- ///
- /// 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
- /// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
- /// CoOwner authority. Its one hard floor is : a command refuses any
- /// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
- /// buggy sidecar can never ban, kick, or otherwise touch the Owner.
- ///
- /// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
- /// Attribution rides on a required "actor" field (the website staff user); every applied
- /// action logs to the console and emits an admin.audit event the website persists.
- ///
- public static class BridgeAdmin
- {
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- BridgeBoot.RegisterHandler("admin.kick", OnKick);
- BridgeBoot.RegisterHandler("admin.ban", OnBan);
- BridgeBoot.RegisterHandler("admin.unban", OnUnban);
- BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
- }
-
- // ---- admin.kick ----
-
- /// Disconnects every live session of the target account. Target by serial or account.
- private static void OnKick(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var actor = BridgeJson.GetString(o, "actor");
- const string action = "kick";
-
- if (!Ready(reqId, action, actor))
- return;
-
- var acct = ResolveTargetAccount(o);
- if (acct == null)
- {
- Err(reqId, action, "unknown or accountless target");
- return;
- }
-
- if (Protected(acct))
- {
- Err(reqId, action, "target is protected staff; refused");
- return;
- }
-
- int kicked = KickAccountSessions(acct);
- var reason = Reason(o);
-
- Log(actor, action, acct.Username, reason);
- BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
- .Num("sessions", kicked)
- .Str("reason", reason)
- .End());
-
- var sb = BridgeJson.Begin("admin.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- admin.ban ----
-
- ///
- /// Bans an account (offline-capable) and disconnects any live sessions. A positive
- /// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
- /// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
- ///
- private static void OnBan(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var actor = BridgeJson.GetString(o, "actor");
- const string action = "ban";
-
- if (!Ready(reqId, action, actor))
- return;
-
- var acct = ResolveTargetAccount(o);
- if (acct == null)
- {
- Err(reqId, action, "unknown or accountless target");
- return;
- }
-
- if (Protected(acct))
- {
- Err(reqId, action, "target is protected staff; refused");
- return;
- }
-
- int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
- if (durationSec < 0)
- durationSec = 0;
- if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
- durationSec = BridgeConfig.AdminBanMaxDurationSec;
-
- if (durationSec > 0)
- acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
- else
- acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
-
- // SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
- acct.SetTag("BanDealer", WebActor(actor));
- acct.Banned = true;
-
- int kicked = KickAccountSessions(acct);
- var reason = Reason(o);
-
- Log(actor, action, acct.Username, reason);
- BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
- .Num("durationSec", durationSec)
- .Num("sessions", kicked)
- .Str("reason", reason)
- .End());
-
- var sb = BridgeJson.Begin("admin.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- admin.unban ----
-
- private static void OnUnban(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var actor = BridgeJson.GetString(o, "actor");
- const string action = "unban";
-
- if (!Ready(reqId, action, actor))
- return;
-
- var acct = ResolveTargetAccount(o);
- if (acct == null)
- {
- Err(reqId, action, "unknown or accountless target");
- return;
- }
-
- acct.Banned = false;
- acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
-
- var reason = Reason(o);
-
- Log(actor, action, acct.Username, reason);
- BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
- .Str("reason", reason)
- .End());
-
- Ok(reqId, action, acct.Username);
- }
-
- // ---- admin.broadcast ----
-
- private static void OnBroadcast(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var actor = BridgeJson.GetString(o, "actor");
- const string action = "broadcast";
-
- if (!Ready(reqId, action, actor))
- return;
-
- var text = BridgeJson.GetString(o, "text");
- if (String.IsNullOrEmpty(text))
- {
- Err(reqId, action, "missing text");
- return;
- }
-
- if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
- text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
-
- // Default to the staff-broadcast green; callers may override.
- int hue = BridgeJson.GetInt(o, "hue", 0x35);
-
- World.Broadcast(hue, false, text);
-
- Log(actor, action, null, text);
- BridgeLink.Emit(AuditBegin(action, actor, null)
- .Num("hue", hue)
- .Str("text", text)
- .End());
-
- var sb = BridgeJson.Begin("admin.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- shared prologue / replies ----
-
- /// Common gate: the write plane must be enabled and an actor must be present.
- private static bool Ready(string reqId, string action, string actor)
- {
- if (!BridgeConfig.AdminWriteEnabled)
- {
- Err(reqId, action, "admin write plane disabled");
- return false;
- }
-
- if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
- {
- Err(reqId, action, "missing actor");
- return false;
- }
-
- return true;
- }
-
- private static void Ok(string reqId, string action, string target)
- {
- var sb = BridgeJson.Begin("admin.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action);
- if (target != null) sb.Str("target", target);
- BridgeLink.Emit(sb.End());
- }
-
- private static void Err(string reqId, string action, string reason)
- {
- var sb = BridgeJson.Begin("admin.error");
- if (reqId != null) sb.Str("reqId", reqId);
- if (action != null) sb.Str("action", action);
- sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
-
- ///
- /// 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
- /// client issued the action. The in-game counterpart (origin=in-game) is emitted from
- /// BridgeEvents; see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
- ///
- private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
- {
- return BridgeJson.Begin("admin.audit")
- .Str("origin", "web")
- .Str("action", action)
- .Str("actor", WebActor(actor))
- .Str("target", target);
- }
-
- private static string WebActor(string actor)
- {
- return "web:" + actor;
- }
-
- /// Reads and length-clamps the optional reason string.
- private static string Reason(Dictionary o)
- {
- var reason = BridgeJson.GetString(o, "reason");
- if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
- reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
- return reason;
- }
-
- private static void Log(string actor, string action, string target, string detail)
- {
- Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
- WebActor(actor), action, target ?? "-", detail ?? "-");
- }
-
- // ---- target resolution & floor ----
-
- ///
- /// Resolves the command's target account, by "serial" (a player mobile's account) or by
- /// "account" (username). Returns null if neither resolves to a real account. Public so the
- /// account plane (unlink) resolves targets the same way the moderation plane does.
- ///
- public static Account ResolveTargetAccount(Dictionary o)
- {
- var serialStr = BridgeJson.GetString(o, "serial");
- if (serialStr != null)
- {
- var m = ResolveSerial(serialStr);
- return m == null ? null : m.Account as Account;
- }
-
- var acctName = BridgeJson.GetString(o, "account");
- return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
- }
-
- ///
- /// The one shard-side safety floor. Protects any account whose effective access level —
- /// the account's own or the highest of its characters' — is at or above the configured
- /// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
- /// so the account plane (unlink) enforces the identical floor.
- ///
- public static bool Protected(Account acct)
- {
- var lvl = acct.AccessLevel;
-
- for (int i = 0; i < acct.Length; i++)
- {
- var m = acct[i];
- if (m != null && m.AccessLevel > lvl)
- lvl = m.AccessLevel;
- }
-
- return lvl >= BridgeConfig.AdminAccessFloor;
- }
-
- ///
- /// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
- /// (rather than walking the account's characters) also catches a session parked at
- /// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
- /// mutates the instance set.
- ///
- private static int KickAccountSessions(Account acct)
- {
- var doomed = new List();
-
- foreach (var ns in NetState.Instances)
- {
- if (ns != null && ns.Account == acct)
- doomed.Add(ns);
- }
-
- foreach (var ns in doomed)
- ns.Dispose();
-
- return doomed.Count;
- }
-
- private static Mobile ResolveSerial(string serialStr)
- {
- try
- {
- var s = serialStr.Trim();
- int value;
-
- if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
- value = Convert.ToInt32(s.Substring(2), 16);
- else
- value = Convert.ToInt32(s, 10);
-
- return World.FindMobile(value);
- }
- catch
- {
- return null;
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
deleted file mode 100644
index 4af7e27..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ /dev/null
@@ -1,210 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Commands;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Lifecycle wiring. Boot order (Server/Main.cs:544-562, all on the Core thread):
- ///
- /// Configure() -> World.Load() -> Initialize() -> EventSink.ServerStarted
- ///
- /// Config is read in Configure. Handlers are attached in Initialize. The socket opens on
- /// ServerStarted, once the world is actually there to describe.
- ///
- /// EventSink.Shutdown does NOT fire on a crash (Server/Main.cs:198,313), so the sidecar
- /// must treat socket EOF as normal and re-handshake rather than waiting for a goodbye.
- ///
- public static class BridgeBoot
- {
- private static readonly Dictionary>> _handlers =
- new Dictionary>>(StringComparer.Ordinal);
-
- ///
- /// Identifies this run of the shard. It is stable across sidecar reconnects and changes
- /// on every shard restart, which is how the sidecar tells "I reconnected" (keep my
- /// cached state) from "the shard restarted" (discard it).
- ///
- private static string _bootId;
-
- public static void Configure()
- {
- BridgeConfig.Configure();
- }
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- {
- Console.WriteLine("[Bridge] disabled by config");
- return;
- }
-
- CommandSystem.Register("bridge", AccessLevel.Administrator, Bridge_OnCommand);
-
- RegisterHandler("ping", OnPing);
-
- BridgeLink.InboundLine += OnInboundLine;
- BridgeLink.Connected_Core += EmitHello;
-
- EventSink.ServerStarted += OnServerStarted;
- EventSink.Shutdown += OnShutdown;
- EventSink.Crashed += OnCrashed;
-
- Console.WriteLine("[Bridge] {0}", BridgeConfig.Describe());
- }
-
- /// Handlers run on the Core thread. They may touch the world freely.
- public static void RegisterHandler(string kind, Action> handler)
- {
- _handlers[kind] = handler;
- }
-
- private static void OnServerStarted()
- {
- _bootId = Guid.NewGuid().ToString("N");
- BridgeLink.Start();
- }
-
- ///
- /// Core thread, once per connection. The sidecar restarts independently of the shard,
- /// so this is sent on every connect rather than once at boot — otherwise a sidecar that
- /// came up second would never learn which shard it is talking to.
- ///
- private static void EmitHello()
- {
- BridgeLink.Emit(BridgeJson.Begin("server.hello")
- .Str("shard", Server.Misc.ServerList.ServerName)
- .Str("bootId", _bootId)
- .Num("connects", BridgeLink.Connects)
- .Num("items", World.Items.Count)
- .Num("mobiles", World.Mobiles.Count)
- .Num("accounts", Accounting.Accounts.Count)
- .End());
- }
-
- private static void OnShutdown(ShutdownEventArgs e)
- {
- BridgeLink.Emit(BridgeJson.Begin("server.shutdown").End());
-
- // Stop() joins the link thread for up to 2s, which gives the writer a chance to drain
- // the goodbye. Best effort: the sidecar must not depend on receiving it.
- BridgeLink.Stop();
- }
-
- private static void OnCrashed(CrashedEventArgs e)
- {
- try
- {
- BridgeLink.Emit(BridgeJson.Begin("server.crashed")
- .Str("error", e.Exception == null ? null : e.Exception.Message)
- .End());
-
- BridgeLink.Stop();
- }
- catch
- {
- // The process is already going down. Never make a crash worse.
- }
- }
-
- /// Core thread, one call per inbound line.
- private static void OnInboundLine(string line)
- {
- var obj = BridgeJson.Parse(line);
-
- if (obj == null)
- {
- Console.WriteLine("[Bridge] malformed inbound line, ignoring");
- return;
- }
-
- var kind = BridgeJson.GetString(obj, "kind");
-
- if (kind == null)
- return;
-
- Action> handler;
-
- if (!_handlers.TryGetValue(kind, out handler))
- {
- Console.WriteLine("[Bridge] no handler for inbound kind '{0}'", kind);
- return;
- }
-
- handler(obj);
- }
-
- private static void OnPing(Dictionary o)
- {
- var sb = BridgeJson.Begin("pong");
-
- var id = BridgeJson.GetString(o, "id");
-
- if (id != null)
- sb.Str("id", id);
-
- BridgeLink.Emit(sb.End());
- }
-
- [Usage("bridge [status | reload | ping | sweepnow]")]
- [Description("Inspects and controls the sidecar link.")]
- private static void Bridge_OnCommand(CommandEventArgs e)
- {
- var arg = e.Length > 0 ? e.GetString(0).ToLowerInvariant() : "status";
-
- switch (arg)
- {
- case "reload":
- BridgeConfig.Load();
- BridgeSweeps.Rearm();
- BridgePages.Rearm();
- BridgeChamps.Rearm();
- BridgeSocial.Rearm();
- BridgeGovernance.Rearm();
- BridgePresence.Rearm();
- BridgeHousing.Rearm();
- e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
- e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
- break;
-
- case "ping":
- BridgeLink.Emit(BridgeJson.Begin("ping").End());
- e.Mobile.SendMessage("Bridge: ping queued.");
- break;
-
- case "sweepnow":
- BridgeSweeps.SweepOnce();
- BridgeChamps.SweepOnce();
- BridgeSocial.SweepOnce();
- BridgeGovernance.SweepOnce();
- BridgePresence.SweepOnce();
- BridgeHousing.SweepOnce();
- e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
- e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
- break;
-
- default:
- e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
- e.Mobile.SendMessage(
- "Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}",
- BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
- BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
- e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
- e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
- break;
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeChamps.cs b/overlay/Scripts/Custom/Bridge/BridgeChamps.cs
deleted file mode 100644
index 8c36273..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeChamps.cs
+++ /dev/null
@@ -1,287 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-using Server.Engines.CannedEvil;
-using Server.Engines.MiniChamps;
-using Server.Mobiles;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// The champion-spawn stream. Like the streams in , this is polled:
- /// none of the three champion families expose an EventSink, so their whole lifecycle lives
- /// inside a per-second SliceTimer and is invisible to a subscriber. Instead we enumerate them
- /// each tick, fold each to a small record, and emit `champ.update` only when that record
- /// changes. A 5-10s sweep is well within the site's tolerance and the world holds only a
- /// handful of spawns, so the pass is trivially cheap.
- ///
- /// Three families, distinguished by the `category` field:
- /// champion - ChampionSpawn: the classic Felucca-style altar (type/level/kills/boss/cooldown)
- /// mini - MiniChamp: the TerMur mini-champ controller (type/level, auto-restarts)
- /// sea - BaseSeaChampion: a High Seas world boss Mobile, alive only while summoned
- ///
- /// Status folds public fields into three values (no core patch needed):
- /// active - running / alive
- /// cooldown - stopped but a restart is pending (ChampionSpawn: RestartTime ahead; MiniChamp:
- /// inactive, since it always re-arms a restart)
- /// dormant - stopped with nothing scheduled (ChampionSpawn only; a GM must turn it on)
- ///
- /// The sidecar keeps the latest record per serial as a live board. A permanent controller's
- /// row lives as long as the item; a transient sea boss is removed with `champ.remove` when it
- /// dies or despawns. A (re)connection clears the diff cache (see OnConnected) so the next
- /// sweep re-emits every spawn in full, rebuilding a sidecar that restarted on its own.
- ///
- public static class BridgeChamps
- {
- private static Timer _timer;
-
- // Last-emitted signature per tracked serial. A serial absent from this map has never been
- // emitted (or the cache was cleared on reconnect), so its next sweep counts as a change.
- // Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
- private static readonly Dictionary _last = new Dictionary();
-
- private static long _sweeps, _emitted, _removed;
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- // Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
- // independently of the shard rebuilds its state within one sweep.
- BridgeLink.Connected_Core += OnConnected;
- Rearm();
- }
-
- private static void OnConnected()
- {
- _last.Clear();
- }
-
- /// Stops and recreates the timer from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- Stop();
-
- _timer = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.ChampSweepSeconds),
- ChampSweep);
- }
-
- public static void Stop()
- {
- if (_timer != null) { _timer.Stop(); _timer = null; }
- }
-
- public static string Status()
- {
- return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
- _sweeps, _emitted, _removed, _last.Count);
- }
-
- /// Runs one sweep now. Wired into `[bridge sweepnow`.
- public static void SweepOnce()
- {
- ChampSweep();
- }
-
- private static void ChampSweep()
- {
- try
- {
- _sweeps++;
-
- if (!BridgeLink.Connected)
- return; // nothing is listening; do not fill the queue with perishable snapshots
-
- var seen = new HashSet();
-
- foreach (var s in World.Items.Values.OfType())
- {
- if (s.Deleted)
- continue;
- Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
- }
-
- foreach (var s in World.Items.Values.OfType())
- {
- if (s.Deleted)
- continue;
- Track(seen, s.Serial, SigMini(s), WriteMini(s));
- }
-
- foreach (var b in World.Mobiles.Values.OfType())
- {
- if (b.Deleted || !b.Alive)
- continue;
- Track(seen, b.Serial, SigSea(b), WriteSea(b));
- }
-
- // Anything tracked last sweep but not seen now has gone away (a controller deleted, a
- // sea boss slain). Tell the sidecar to drop its board row.
- var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
- foreach (var serial in gone)
- {
- _last.Remove(serial);
- BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
- _removed++;
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] champ sweep threw: {0}", ex.Message);
- }
- }
-
- /// Records a spawn as seen and emits it only if its signature changed since last sweep.
- private static void Track(HashSet seen, Serial serial, string sig, string line)
- {
- seen.Add(serial);
-
- string prior;
- if (_last.TryGetValue(serial, out prior) && prior == sig)
- return; // unchanged since last emit
-
- _last[serial] = sig;
- BridgeLink.Emit(line);
- _emitted++;
- }
-
- // ---- ChampionSpawn (classic) ----
-
- private static string StatusOf(ChampionSpawn s)
- {
- if (s.Active)
- return "active";
- if (s.RestartTime > DateTime.UtcNow)
- return "cooldown";
- return "dormant";
- }
-
- // The volatile fields that define a meaningful change. Kept in sync with WriteChampion so the
- // site never misses a level, a kill-count tick, a boss pop, or a status/cooldown transition.
- private static string SigChampion(ChampionSpawn s)
- {
- return String.Concat(
- "champion|", StatusOf(s), "|",
- s.Level.ToString(), "|",
- s.Kills.ToString(), "|",
- (s.Champion != null && !s.Champion.Deleted) ? "1" : "0", "|",
- s.RestartTime.Ticks.ToString(), "|",
- s.ExpireTime.Ticks.ToString());
- }
-
- private static string WriteChampion(ChampionSpawn s)
- {
- var status = StatusOf(s);
- var bossUp = s.Champion != null && !s.Champion.Deleted;
-
- // Prefer a staff-set display name, then the group, then the spawn type.
- string name = !String.IsNullOrEmpty(s.SpawnName) ? s.SpawnName
- : !String.IsNullOrEmpty(s.GroupName) ? s.GroupName
- : s.Type.ToString();
-
- var sb = BridgeJson.Begin("champ.update")
- .Ser("serial", s.Serial)
- .Str("category", "champion")
- .Str("type", s.Type.ToString())
- .Str("name", name)
- .Str("status", status)
- .Bool("active", s.Active)
- .Num("level", s.Level)
- .Num("rank", s.Rank)
- .Num("kills", s.Kills)
- .Num("maxKills", s.MaxKills)
- .Bool("bossUp", bossUp)
- .Bool("autoRestart", s.AutoRestart)
- .Str("map", s.Map == null ? null : s.Map.Name)
- .Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
-
- if (bossUp)
- sb.Str("boss", String.IsNullOrEmpty(s.Champion.Name) ? s.Champion.GetType().Name : s.Champion.Name);
-
- // Cooldown ETA: when the spawn will auto-restart. Only meaningful while on cooldown.
- if (status == "cooldown")
- sb.Str("restartAt", s.RestartTime.ToUniversalTime().ToString("o"));
-
- // Level-expiry ETA: when the current level times out if kills stall. Only while active.
- if (s.Active)
- sb.Str("expireAt", s.ExpireTime.ToUniversalTime().ToString("o"));
-
- return sb.End();
- }
-
- // ---- MiniChamp (TerMur mini-champs) ----
-
- // MiniChamp exposes no kills, no boss handle, and no restart-time getter. When inactive it has
- // always re-armed a restart, so inactive folds to "cooldown" (there is no dormant state and no
- // ETA to report).
- private static string SigMini(MiniChamp s)
- {
- return String.Concat(
- "mini|", (s.Active ? "active" : "cooldown"), "|", s.Level.ToString());
- }
-
- private static string WriteMini(MiniChamp s)
- {
- var status = s.Active ? "active" : "cooldown";
- var info = MiniChampInfo.GetInfo(s.Type);
-
- var sb = BridgeJson.Begin("champ.update")
- .Ser("serial", s.Serial)
- .Str("category", "mini")
- .Str("type", s.Type.ToString())
- .Str("name", s.Type.ToString())
- .Str("status", status)
- .Bool("active", s.Active)
- .Num("level", s.Level)
- .Bool("bossUp", false)
- .Bool("autoRestart", true)
- .Str("map", s.Map == null ? null : s.Map.Name)
- .Num("x", s.X).Num("y", s.Y).Num("z", s.Z);
-
- if (info != null)
- sb.Num("maxLevel", info.MaxLevel);
-
- return sb.End();
- }
-
- // ---- BaseSeaChampion (High Seas world boss) ----
-
- // A sea champion is a Mobile, not a controller: it exists only while summoned and alive, so it
- // is always "active" on the board and leaves via champ.remove when slain. Position and health
- // are tracked so the board can show a live "world boss here, N% hp".
- private static string SigSea(BaseSeaChampion b)
- {
- return String.Concat(
- "sea|", b.Hits.ToString(), "|", b.X.ToString(), "|", b.Y.ToString());
- }
-
- private static string WriteSea(BaseSeaChampion b)
- {
- string name = String.IsNullOrEmpty(b.Name) ? b.GetType().Name : b.Name;
-
- return BridgeJson.Begin("champ.update")
- .Ser("serial", b.Serial)
- .Str("category", "sea")
- .Str("type", b.GetType().Name)
- .Str("name", name)
- .Str("status", "active")
- .Bool("active", true)
- .Bool("bossUp", true)
- .Str("boss", name)
- .Num("hits", b.Hits)
- .Num("hitsMax", b.HitsMax)
- .Str("map", b.Map == null ? null : b.Map.Name)
- .Num("x", b.X).Num("y", b.Y).Num("z", b.Z)
- .End();
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
deleted file mode 100644
index 21907ce..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-using System;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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 operator pairs with this (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §2).
- ///
- public enum SignupMode
- {
- Website, // website is the account authority; in-game auto-create should be off
- Game, // game server is the authority; account.create is refused
- Hybrid // either side may create
- }
-
- ///
- /// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
- /// reads as "Bridge.Port" here.
- ///
- /// Loaded in Configure(), which ScriptCompiler invokes before World.Load.
- ///
- public static class BridgeConfig
- {
- public static string Host { get; private set; }
- public static int Port { get; private set; }
- public static int QueueCap { get; private set; }
-
- public static int StatSweepSeconds { get; private set; }
- public static int DecaySweepSeconds { get; private set; }
- public static int EconomySweepSeconds { get; private set; }
- public static int PageSweepSeconds { get; private set; }
- public static int ChampSweepSeconds { get; private set; }
- public static int GuildSweepSeconds { get; private set; }
- public static int CitySweepSeconds { get; private set; }
- public static int PresenceSweepSeconds { get; private set; }
- public static int HousingSweepSeconds { get; private set; }
-
- public static string LinkUrl { get; private set; }
-
- public static int TownCrierMaxLines { get; private set; }
- public static int TownCrierMaxLineLength { get; private set; }
- public static int TownCrierMaxActive { get; private set; }
- public static int TownCrierMaxDurationSec { get; private set; }
-
- // 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 NewsMaxBodyLength { get; private set; }
- public static int NewsMaxExternal { get; private set; }
- public static int NewsAnnounceDurationSec { get; private set; }
-
- public static bool AdminWriteEnabled { get; private set; }
- public static AccessLevel AdminAccessFloor { get; private set; }
- public static int AdminBroadcastMaxLength { get; private set; }
- public static int AdminReasonMaxLength { get; private set; }
- public static int AdminBanMaxDurationSec { get; private set; }
-
- // ---- 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 bool AccountCreateEnabled { get; private set; }
- public static bool RequireIpForCreate { get; private set; }
- public static int AccountNameMaxLength { get; private set; }
- public static int AccountPasswordMaxLength { get; private set; }
-
- public static bool Enabled { get; private set; }
-
- public static void Configure()
- {
- Load();
- }
-
- /// Re-readable at runtime via `[bridge reload`.
- public static void Load()
- {
- Enabled = Config.Get("Bridge.Enabled", true);
-
- Host = Config.Get("Bridge.Host", "127.0.0.1");
- Port = Config.Get("Bridge.Port", 7788);
- QueueCap = Config.Get("Bridge.QueueCap", 10000);
-
- StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
- DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
- EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
- PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
- if (PageSweepSeconds < 1)
- PageSweepSeconds = 1;
-
- ChampSweepSeconds = Config.Get("Bridge.ChampSweepSeconds", 10);
- if (ChampSweepSeconds < 1)
- ChampSweepSeconds = 1;
-
- // 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.
- GuildSweepSeconds = Config.Get("Bridge.GuildSweepSeconds", 60);
- if (GuildSweepSeconds < 1)
- GuildSweepSeconds = 1;
-
- CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
- if (CitySweepSeconds < 1)
- CitySweepSeconds = 1;
-
- PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
- if (PresenceSweepSeconds < 1)
- PresenceSweepSeconds = 1;
-
- HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
- if (HousingSweepSeconds < 1)
- HousingSweepSeconds = 1;
-
- LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
-
- TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
- TownCrierMaxLineLength = Config.Get("Bridge.TownCrierMaxLineLength", 200);
- TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
- TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
-
- NewsMaxTitleLength = Config.Get("Bridge.NewsMaxTitleLength", 100);
- NewsMaxBodyLength = Config.Get("Bridge.NewsMaxBodyLength", 2000);
- NewsMaxExternal = Config.Get("Bridge.NewsMaxExternal", 20);
- NewsAnnounceDurationSec = Config.Get("Bridge.NewsAnnounceDurationSec", 300);
- if (NewsAnnounceDurationSec < 1)
- NewsAnnounceDurationSec = 1;
-
- AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
- AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
- AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
- AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
- AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
-
- // Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
- // unrecognized* value falls back to Game (the safest — no website creation), so a
- // typo can never accidentally open provisioning.
- Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
- // Default follows the mode: creation is on unless the shard is game-authority.
- AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
- RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
- AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
- AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
- if (AccountNameMaxLength < 1)
- AccountNameMaxLength = 1;
- if (AccountPasswordMaxLength < 1)
- AccountPasswordMaxLength = 1;
-
- if (QueueCap < 16)
- QueueCap = 16;
-
- WarnOnSignupMismatch();
- }
-
- ///
- /// The bridge governs only the account.create verb; ServUO's in-game first-login
- /// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
- /// disagree is quietly broken (website-only that still auto-creates in game, or a mode
- /// that expects in-game creation with it switched off), so surface the contradiction
- /// loudly rather than silently doing the permissive thing.
- ///
- private static void WarnOnSignupMismatch()
- {
- var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
-
- if (Signup == SignupMode.Website && autoCreate)
- Console.WriteLine(
- "[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
- + "an in-game login of any new name still mints an account. Set it false for website-only.");
- else if (Signup == SignupMode.Game && !autoCreate)
- Console.WriteLine(
- "[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
- + "in-game creation is off and account.create is refused, so no account can be created.");
- else if (Signup == SignupMode.Hybrid && !autoCreate)
- Console.WriteLine(
- "[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
- + "in-game first-login creation is off. Only website account.create will work.");
- }
-
- ///
- /// Parses a SignupMode name, case-insensitively, falling back to
- /// on anything unrecognized so a typo can never open provisioning wider than intended.
- ///
- private static SignupMode ParseSignupMode(string value, SignupMode fallback)
- {
- SignupMode parsed;
- if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
- Enum.IsDefined(typeof(SignupMode), parsed))
- return parsed;
-
- Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
- return fallback;
- }
-
- ///
- /// Parses an AccessLevel name from config, case-insensitively, falling back to the given
- /// default on anything unrecognized so a typo can never open the floor wider than intended.
- ///
- private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
- {
- AccessLevel parsed;
- if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
- Enum.IsDefined(typeof(AccessLevel), parsed))
- return parsed;
-
- Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
- return fallback;
- }
-
- public static string Describe()
- {
- return String.Format(
- "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
- Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
- ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
deleted file mode 100644
index a05fbea..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs
+++ /dev/null
@@ -1,440 +0,0 @@
-using System;
-using System.Text;
-
-using Server.Accounting;
-using Server.Commands;
-using Server.Mobiles;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// EventSink subscriptions. Every handler runs on the Core thread, synchronously, inside
- /// the code path that raised it. Three rules, all load-bearing:
- ///
- /// 1. Never block. Emit() enqueues and returns; that is the only I/O allowed here.
- /// 2. Never throw. A bridge exception escaping into a game code path is a shard bug,
- /// so every handler body is wrapped.
- /// 3. Never mutate the args. Several of these are veto hooks — AccountLogin has
- /// Accepted/RejectReason, FastWalk has Blocked — and we are an observer, not a
- /// participant.
- ///
- /// Copy primitives out synchronously. Some args objects are pooled and freed immediately
- /// after the event returns.
- ///
- public static class BridgeEvents
- {
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- // Session
- EventSink.Login += OnLogin;
- EventSink.Logout += OnLogout;
- EventSink.AccountLogin += OnAccountLogin;
-
- // Economy
- EventSink.AccountGoldChange += OnGoldChange;
- EventSink.ValidVendorPurchase += OnVendorPurchase;
- EventSink.ValidVendorSell += OnVendorSell;
- EventSink.PlacePlayerVendor += OnVendorPlaced;
-
- // Progression
- EventSink.SkillGain += OnSkillGain;
- EventSink.FameChange += OnFameChange;
- EventSink.KarmaChange += OnKarmaChange;
- EventSink.QuestComplete += OnQuestComplete;
-
- // Death
- EventSink.PlayerDeath += OnPlayerDeath;
- EventSink.PlayerMurdered += OnPlayerMurdered;
- EventSink.OnKilledBy += OnKilledBy;
-
- // Cheat detection and staff audit
- EventSink.FastWalk += OnFastWalk;
- EventSink.OnPropertyChanged += OnStaffPropertySet;
- EventSink.Command += OnStaffCommand;
-
- // Save boundaries
- EventSink.BeforeWorldSave += OnBeforeWorldSave;
- EventSink.AfterWorldSave += OnAfterWorldSave;
-
- Console.WriteLine("[Bridge] event streams attached");
- }
-
- // ---- helpers ----
-
- /// Writes a nested actor object: serial, name, and account when there is one.
- private static StringBuilder Mob(this StringBuilder sb, string field, Mobile m)
- {
- sb.Append(",\"").Append(field).Append("\":");
-
- if (m == null)
- {
- sb.Append("null");
- return sb;
- }
-
- sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
-
- sb.Append(",\"name\":");
- BridgeJson.Escape(sb, m.Name ?? "");
-
- var acct = m.Account as Account;
-
- if (acct != null)
- {
- sb.Append(",\"acct\":");
- BridgeJson.Escape(sb, acct.Username);
- }
-
- sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
- sb.Append('}');
-
- return sb;
- }
-
- private static long ToGold(double currency)
- {
- return (long)(currency * Account.CurrencyThreshold);
- }
-
- private static void Guard(string kind, Action body)
- {
- try
- {
- body();
- }
- catch (Exception ex)
- {
- // Swallow: we are inside a game code path and must not disturb it.
- Console.WriteLine("[Bridge] handler '{0}' threw: {1}", kind, ex.Message);
- }
- }
-
- // ---- session ----
-
- private static void OnLogin(LoginEventArgs e)
- {
- Guard("mob.login", () =>
- {
- var m = e.Mobile;
-
- if (m == null)
- return;
-
- // Carry the linked website id on the login anchor so the sidecar can attribute
- // this session (and everything after it) to a site user without a lookup.
- var webId = BridgeAccountLink.WebIdFor(m.Account as Account);
-
- var sb = BridgeJson.Begin("mob.login")
- .Mob("who", m)
- .Str("map", m.Map == null ? null : m.Map.Name)
- .Num("x", m.X).Num("y", m.Y).Num("z", m.Z);
-
- if (webId != null)
- sb.Str("webId", webId);
-
- BridgeLink.Emit(sb.End());
- });
- }
-
- private static void OnLogout(LogoutEventArgs e)
- {
- Guard("mob.logout", () =>
- {
- var m = e.Mobile;
-
- if (m == null)
- return;
-
- BridgeLink.Emit(BridgeJson.Begin("mob.logout").Mob("who", m).End());
- });
- }
-
- ///
- /// Veto hook: AccountLoginEventArgs carries Accepted and RejectReason, and a plaintext
- /// Password. We read the username only. The password must never leave the process.
- /// Fires before the auth decision, so this is an attempt, not a result.
- ///
- private static void OnAccountLogin(AccountLoginEventArgs e)
- {
- Guard("account.login.attempt", () =>
- {
- string address = null;
-
- if (e.State != null && e.State.Address != null)
- address = e.State.Address.ToString();
-
- BridgeLink.Emit(BridgeJson.Begin("account.login.attempt")
- .Str("acct", e.Username)
- .Str("ip", address)
- .End());
- });
- }
-
- // ---- economy ----
-
- private static void OnGoldChange(AccountGoldChangeEventArgs e)
- {
- Guard("gold.change", () =>
- {
- var acct = e.Account as Account;
-
- if (acct == null)
- return;
-
- long oldGold = ToGold(e.OldAmount);
- long newGold = ToGold(e.NewAmount);
-
- BridgeLink.Emit(BridgeJson.Begin("gold.change")
- .Str("acct", acct.Username)
- .Num("old", oldGold)
- .Num("new", newGold)
- .Num("delta", newGold - oldGold)
- .End());
- });
- }
-
- ///
- /// ValidVendorPurchase is a validation-stage hook, not a committed sale. Treat as
- /// "attempted". Total is AmountPerUnit times the stack size, not AmountPerUnit.
- ///
- private static void OnVendorPurchase(ValidVendorPurchaseEventArgs e)
- {
- Guard("vendor.buy", () => EmitVendorTrade("vendor.buy", e.Mobile, e.Vendor, e.Bought, e.AmountPerUnit));
- }
-
- private static void OnVendorSell(ValidVendorSellEventArgs e)
- {
- Guard("vendor.sell", () => EmitVendorTrade("vendor.sell", e.Mobile, e.Vendor, e.Sold, e.AmountPerUnit));
- }
-
- private static void EmitVendorTrade(string kind, Mobile who, Mobile vendor, IEntity entity, int perUnit)
- {
- int amount = 1;
- var item = entity as Item;
-
- if (item != null)
- amount = Math.Max(1, item.Amount);
-
- var sb = BridgeJson.Begin(kind)
- .Mob("who", who)
- .Mob("vendor", vendor)
- .Str("item", entity == null ? null : entity.GetType().Name)
- .Num("amount", amount)
- .Num("perUnit", perUnit)
- .Num("total", (long)perUnit * amount)
- .Bool("committed", false); // validation stage; reconcile against gold.change
-
- if (entity != null)
- sb.Ser("itemSerial", entity.Serial);
-
- BridgeLink.Emit(sb.End());
- }
-
- private static void OnVendorPlaced(PlacePlayerVendorEventArgs e)
- {
- Guard("vendor.placed", () =>
- BridgeLink.Emit(BridgeJson.Begin("vendor.placed")
- .Mob("owner", e.Mobile)
- .Mob("vendor", e.Vendor)
- .End()));
- }
-
- // ---- progression ----
-
- ///
- /// Player-only. SkillGain fires for creatures too, and they train constantly: on this
- /// shard a single boot produced 115 gains in four seconds, every one of them an NPC
- /// grinding Meditation. Unfiltered this is a firehose of noise.
- ///
- private static void OnSkillGain(SkillGainEventArgs e)
- {
- Guard("skill.gain", () =>
- {
- if (e.Skill == null || e.From == null || !e.From.Player)
- return;
-
- BridgeLink.Emit(BridgeJson.Begin("skill.gain")
- .Mob("who", e.From)
- .Str("skill", e.Skill.SkillName.ToString())
- .Num("gained", e.Gained)
- .Num("base", e.Skill.Base)
- .Num("cap", e.Skill.Cap)
- .End());
- });
- }
-
- private static void OnFameChange(FameChangeEventArgs e)
- {
- Guard("fame.change", () =>
- {
- if (e.Mobile == null || !e.Mobile.Player)
- return;
-
- BridgeLink.Emit(BridgeJson.Begin("fame.change")
- .Mob("who", e.Mobile)
- .Num("old", e.OldValue)
- .Num("new", e.NewValue)
- .End());
- });
- }
-
- private static void OnKarmaChange(KarmaChangeEventArgs e)
- {
- Guard("karma.change", () =>
- {
- if (e.Mobile == null || !e.Mobile.Player)
- return;
-
- BridgeLink.Emit(BridgeJson.Begin("karma.change")
- .Mob("who", e.Mobile)
- .Num("old", e.OldValue)
- .Num("new", e.NewValue)
- .End());
- });
- }
-
- private static void OnQuestComplete(QuestCompleteEventArgs e)
- {
- Guard("quest.complete", () =>
- BridgeLink.Emit(BridgeJson.Begin("quest.complete")
- .Mob("who", e.Mobile)
- .Str("quest", e.QuestType == null ? null : e.QuestType.Name)
- .End()));
- }
-
- // ---- death ----
-
- private static void OnPlayerDeath(PlayerDeathEventArgs e)
- {
- Guard("player.death", () =>
- BridgeLink.Emit(BridgeJson.Begin("player.death")
- .Mob("who", e.Mobile)
- .Mob("killer", e.Killer)
- .End()));
- }
-
- private static void OnPlayerMurdered(PlayerMurderedEventArgs e)
- {
- Guard("player.murdered", () =>
- BridgeLink.Emit(BridgeJson.Begin("player.murdered")
- .Mob("victim", e.Victim)
- .Mob("murderer", e.Murderer)
- .End()));
- }
-
- ///
- /// Fires for creatures too. Only a kill involving a player is interesting, and filtering
- /// here rather than in the sidecar keeps the mob-grinding firehose off the socket.
- ///
- private static void OnKilledBy(OnKilledByEventArgs e)
- {
- Guard("mob.killed", () =>
- {
- var killed = e.Killed;
- var killer = e.KilledBy;
-
- bool involvesPlayer = (killed != null && killed.Player) || (killer != null && killer.Player);
-
- if (!involvesPlayer)
- return;
-
- BridgeLink.Emit(BridgeJson.Begin("mob.killed")
- .Mob("killed", killed)
- .Mob("killer", killer)
- .End());
- });
- }
-
- // ---- cheat detection and staff audit ----
-
- ///
- /// Veto hook: FastWalkEventArgs.Blocked gates the move. Read only. The args carry only
- /// a NetState, and NetState.Mobile can be null mid-handshake.
- ///
- private static void OnFastWalk(FastWalkEventArgs e)
- {
- Guard("cheat.fastwalk", () =>
- {
- var state = e.NetState;
-
- if (state == null)
- return;
-
- var sb = BridgeJson.Begin("cheat.fastwalk")
- .Mob("who", state.Mobile);
-
- if (state.Address != null)
- sb.Str("ip", state.Address.ToString());
-
- BridgeLink.Emit(sb.End());
- });
- }
-
- ///
- /// Raised only from Scripts/Commands/Properties.cs, i.e. staff `[set`. This is a
- /// GM-abuse audit trail, not a stat-change stream. One of its three raise sites passes
- /// a null Mobile, so the staffer is not always known.
- ///
- private static void OnStaffPropertySet(OnPropertyChangedEventArgs e)
- {
- Guard("audit.set", () =>
- {
- if (e.Property == null)
- return;
-
- var sb = BridgeJson.Begin("audit.set")
- .Mob("staff", e.Mobile)
- .Str("prop", e.Property.Name)
- .Str("target", e.Instance == null ? null : e.Instance.GetType().Name)
- .Str("old", e.OldValue == null ? null : e.OldValue.ToString())
- .Str("new", e.NewValue == null ? null : e.NewValue.ToString());
-
- var ent = e.Instance as IEntity;
-
- if (ent != null)
- sb.Ser("targetSerial", ent.Serial);
-
- BridgeLink.Emit(sb.End());
- });
- }
-
- private static void OnStaffCommand(CommandEventArgs e)
- {
- Guard("audit.command", () =>
- {
- if (e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
- return; // player commands are noise; staff commands are the audit trail
-
- BridgeLink.Emit(BridgeJson.Begin("audit.command")
- .Mob("staff", e.Mobile)
- .Str("command", e.Command)
- .Str("args", e.ArgString)
- .End());
- });
- }
-
- // ---- save boundaries ----
-
- private static void OnBeforeWorldSave(BeforeWorldSaveEventArgs e)
- {
- Guard("world.save.before", () =>
- BridgeLink.Emit(BridgeJson.Begin("world.save.before").End()));
- }
-
- ///
- /// A natural checkpoint: the sidecar can treat this as a consistency boundary. Note that
- /// timers and inbound commands do not run during the save itself.
- ///
- private static void OnAfterWorldSave(AfterWorldSaveEventArgs e)
- {
- Guard("world.save.after", () =>
- BridgeLink.Emit(BridgeJson.Begin("world.save.after")
- .Num("items", World.Items.Count)
- .Num("mobiles", World.Mobiles.Count)
- .End()));
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs b/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
deleted file mode 100644
index c2858f0..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
+++ /dev/null
@@ -1,175 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Engines.CityLoyalty;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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
- /// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises
- /// an EventSink, so — like and — the set
- /// is polled and each city emits `city.update` only when its signature changes. Governors turn
- /// over on the order of weeks, so a slow sweep (default 5 min) is ample.
- ///
- /// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with
- /// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a
- /// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would
- /// otherwise fire spuriously for every city.
- ///
- /// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here.
- ///
- public static class BridgeGovernance
- {
- private static Timer _timer;
-
- // City enum value -> last-emitted signature.
- private static readonly Dictionary _last = new Dictionary();
-
- private static long _sweeps, _emitted;
- private static bool _warnedDisabled;
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- BridgeLink.Connected_Core += OnConnected;
- Rearm();
- }
-
- private static void OnConnected()
- {
- _last.Clear();
- }
-
- /// Stops and recreates the timer from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- Stop();
-
- _timer = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds),
- CitySweep);
- }
-
- public static void Stop()
- {
- if (_timer != null) { _timer.Stop(); _timer = null; }
- }
-
- public static string Status()
- {
- return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})",
- CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count);
- }
-
- /// Runs one sweep now. Wired into `[bridge sweepnow`.
- public static void SweepOnce()
- {
- CitySweep();
- }
-
- private static void CitySweep()
- {
- try
- {
- _sweeps++;
-
- if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null)
- {
- if (!_warnedDisabled)
- {
- Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle.");
- _warnedDisabled = true;
- }
- return;
- }
-
- if (!BridgeLink.Connected)
- return; // nothing is listening; do not fill the queue with perishable snapshots
-
- foreach (var city in CityLoyaltySystem.Cities)
- {
- if (city == null)
- continue;
-
- var sig = Signature(city);
-
- int key = (int)city.City;
-
- string prior;
- if (_last.TryGetValue(key, out prior) && prior == sig)
- continue; // unchanged since last emit
-
- _last[key] = sig;
- BridgeLink.Emit(WriteCity(city));
- _emitted++;
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message);
- }
- }
-
- // The volatile fields: governor, governor-elect, and the election phase / candidate count.
- private static string Signature(CityLoyaltySystem city)
- {
- var gov = city.Governor == null ? 0 : city.Governor.Serial.Value;
- var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value;
-
- var e = city.Election;
- var phase = ElectionPhase(e);
- var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
-
- return String.Concat(
- gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString());
- }
-
- private static string WriteCity(CityLoyaltySystem city)
- {
- var e = city.Election;
- var phase = ElectionPhase(e);
- var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count;
-
- var sb = BridgeJson.Begin("city.update")
- .Str("city", city.City.ToString())
- .Str("electionPhase", phase)
- .Num("candidates", candidates);
-
- sb.Actor("governor", city.Governor);
- sb.Actor("governorElect", city.GovernorElect);
-
- if (e != null && e.Ongoing)
- sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o"));
-
- return sb.End();
- }
-
- /// Folds the election state into one of: none / nominate / vote / pending.
- private static string ElectionPhase(CityElection e)
- {
- if (e == null)
- return "none";
-
- if (e.CanNominate())
- return "nominate";
-
- if (e.CanVote())
- return "vote";
-
- if (e.Ongoing)
- return "pending";
-
- return "none";
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeHousing.cs b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs
deleted file mode 100644
index 31fe51a..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeHousing.cs
+++ /dev/null
@@ -1,165 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-using Server.Multis;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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,
- /// 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
- /// house.update only when a house's signature changes, and house.remove when a house is gone.
- ///
- /// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the
- /// registry is owner→houses; `price` is the house's placement value, not a sale listing.
- ///
- public static class BridgeHousing
- {
- private static Timer _timer;
-
- // house serial -> last-emitted signature.
- private static readonly Dictionary _last = new Dictionary();
-
- private static long _sweeps, _emitted, _removed;
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- BridgeLink.Connected_Core += OnConnected;
- Rearm();
- }
-
- private static void OnConnected()
- {
- _last.Clear();
- }
-
- /// Stops and recreates the timer from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- Stop();
-
- _timer = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds),
- HouseSweep);
- }
-
- public static void Stop()
- {
- if (_timer != null) { _timer.Stop(); _timer = null; }
- }
-
- public static string Status()
- {
- return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})",
- _sweeps, _emitted, _removed, _last.Count);
- }
-
- /// Runs one sweep now. Wired into `[bridge sweepnow`.
- public static void SweepOnce()
- {
- HouseSweep();
- }
-
- private static void HouseSweep()
- {
- try
- {
- _sweeps++;
-
- if (!BridgeLink.Connected)
- return; // nothing is listening; do not fill the queue with perishable snapshots
-
- var seen = new HashSet();
-
- foreach (var house in BaseHouse.AllHouses)
- {
- if (house == null || house.Deleted)
- continue;
-
- seen.Add(house.Serial);
-
- var level = house.DecayLevel; // computed getter — read once
- var sig = Signature(house, level);
-
- string prior;
- if (_last.TryGetValue(house.Serial, out prior) && prior == sig)
- continue; // unchanged since last emit
-
- _last[house.Serial] = sig;
- BridgeLink.Emit(WriteHouse(house, level));
- _emitted++;
- }
-
- var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
- foreach (var serial in gone)
- {
- _last.Remove(serial);
- BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End());
- _removed++;
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message);
- }
- }
-
- private static string Signature(BaseHouse house, DecayLevel level)
- {
- var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value;
- var region = house.Region;
- var regionName = region == null ? "" : (region.Name ?? "");
- var sign = house.Sign;
- var name = sign == null ? "" : (sign.GetName() ?? "");
- var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count;
-
- return String.Concat(
- ownerSerial.ToString(), "|",
- level.ToString(), "|",
- regionName, "|",
- name, "|",
- coOwners.ToString(), "|",
- house.Price.ToString());
- }
-
- private static string WriteHouse(BaseHouse house, DecayLevel level)
- {
- var sb = BridgeJson.Begin("house.update")
- .Ser("serial", house.Serial)
- .Str("decay", level.ToString())
- .Num("price", house.Price)
- .Str("map", house.Map == null ? null : house.Map.Name)
- .Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
-
- var sign = house.Sign;
- if (sign != null)
- sb.Str("name", sign.GetName());
-
- var region = house.Region;
- if (region != null)
- sb.Str("region", region.Name);
-
- sb.Actor("owner", house.Owner);
-
- sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count);
- sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count);
-
- sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
- sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
-
- return sb.End();
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
deleted file mode 100644
index 9d73789..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs
+++ /dev/null
@@ -1,238 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Text;
-using System.Web.Script.Serialization;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Outbound JSON is written by hand into a StringBuilder. It runs on the Core thread for
- /// 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.
- ///
- /// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
- /// correctness beats speed there, and parsing happens on the reader thread anyway.
- ///
- public static class BridgeJson
- {
- private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-
- [ThreadStatic]
- private static JavaScriptSerializer _parser;
-
- public static long NowMs()
- {
- return (long)(DateTime.UtcNow - Epoch).TotalMilliseconds;
- }
-
- // ---- outbound ----
-
- /// Opens an object and writes the `t` and `kind` fields.
- public static StringBuilder Begin(string kind)
- {
- var sb = new StringBuilder(256);
- sb.Append("{\"t\":").Append(NowMs());
- sb.Append(",\"kind\":\"").Append(kind).Append('"');
- return sb;
- }
-
- public static StringBuilder Str(this StringBuilder sb, string name, string value)
- {
- sb.Append(",\"").Append(name).Append("\":");
-
- if (value == null)
- sb.Append("null");
- else
- Escape(sb, value);
-
- return sb;
- }
-
- public static StringBuilder Num(this StringBuilder sb, string name, long value)
- {
- sb.Append(",\"").Append(name).Append("\":").Append(value);
- return sb;
- }
-
- public static StringBuilder Num(this StringBuilder sb, string name, double value)
- {
- sb.Append(",\"").Append(name).Append("\":")
- .Append(value.ToString("R", CultureInfo.InvariantCulture));
- return sb;
- }
-
- public static StringBuilder Bool(this StringBuilder sb, string name, bool value)
- {
- sb.Append(",\"").Append(name).Append("\":").Append(value ? "true" : "false");
- return sb;
- }
-
- /// Serial as the canonical "0x1A2B" string the sidecar keys on.
- public static StringBuilder Ser(this StringBuilder sb, string name, Serial serial)
- {
- sb.Append(",\"").Append(name).Append("\":\"0x")
- .Append(serial.Value.ToString("X")).Append('"');
- return sb;
- }
-
- ///
- /// Writes a nested actor object: serial, name, account (when there is one), the linked
- /// webId (when the account is linked), and the player flag. A `null` mobile writes null.
- /// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a
- /// guild leader / joiner / governor can be attributed to a site user without a lookup.
- ///
- public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
- {
- sb.Append(",\"").Append(name).Append("\":");
-
- if (m == null)
- {
- sb.Append("null");
- return sb;
- }
-
- sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
-
- sb.Append(",\"name\":");
- Escape(sb, m.Name ?? "");
-
- var acct = m.Account as Accounting.Account;
- if (acct != null)
- {
- sb.Append(",\"acct\":");
- Escape(sb, acct.Username);
-
- var webId = BridgeAccountLink.WebIdFor(acct);
- if (webId != null)
- {
- sb.Append(",\"webId\":");
- Escape(sb, webId);
- }
- }
-
- sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
- sb.Append('}');
- return sb;
- }
-
- /// Closes the object. The trailing newline is the frame delimiter.
- public static string End(this StringBuilder sb)
- {
- sb.Append('}');
- return sb.ToString();
- }
-
- public static void Escape(StringBuilder sb, string value)
- {
- sb.Append('"');
-
- for (int i = 0; i < value.Length; i++)
- {
- char c = value[i];
-
- switch (c)
- {
- case '"': sb.Append("\\\""); break;
- case '\\': sb.Append("\\\\"); break;
- case '\n': sb.Append("\\n"); break;
- case '\r': sb.Append("\\r"); break;
- case '\t': sb.Append("\\t"); break;
- case '\b': sb.Append("\\b"); break;
- case '\f': sb.Append("\\f"); break;
- default:
- if (c < ' ')
- sb.Append("\\u").Append(((int)c).ToString("x4"));
- else
- sb.Append(c);
- break;
- }
- }
-
- sb.Append('"');
- }
-
- // ---- inbound ----
-
- ///
- /// Parses one line into a dictionary. Returns null on malformed input rather than
- /// throwing: a bad line from the sidecar must never reach a game code path.
- ///
- public static Dictionary Parse(string line)
- {
- if (String.IsNullOrEmpty(line))
- return null;
-
- try
- {
- if (_parser == null)
- {
- _parser = new JavaScriptSerializer();
- _parser.MaxJsonLength = 1 << 20;
- }
-
- return _parser.Deserialize>(line);
- }
- catch
- {
- return null;
- }
- }
-
- public static string GetString(Dictionary o, string key)
- {
- object v;
-
- if (o == null || !o.TryGetValue(key, out v) || v == null)
- return null;
-
- return v as string ?? Convert.ToString(v, CultureInfo.InvariantCulture);
- }
-
- ///
- /// Extracts a JSON array of strings. JavaScriptSerializer materializes JSON arrays as
- /// object[] (or ArrayList) when the target is object, so handle both and stringify each
- /// element. Returns an empty list for a missing or non-array value, never null.
- ///
- public static List GetStringList(Dictionary o, string key)
- {
- var result = new List();
-
- object v;
- if (o == null || !o.TryGetValue(key, out v) || v == null)
- return result;
-
- var enumerable = v as System.Collections.IEnumerable;
-
- if (enumerable == null || v is string)
- return result;
-
- foreach (var item in enumerable)
- {
- if (item == null)
- continue;
-
- result.Add(item as string ?? Convert.ToString(item, CultureInfo.InvariantCulture));
- }
-
- return result;
- }
-
- public static int GetInt(Dictionary o, string key, int fallback)
- {
- object v;
-
- if (o == null || !o.TryGetValue(key, out v) || v == null)
- return fallback;
-
- try
- {
- return Convert.ToInt32(v, CultureInfo.InvariantCulture);
- }
- catch
- {
- return fallback;
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeLink.cs b/overlay/Scripts/Custom/Bridge/BridgeLink.cs
deleted file mode 100644
index fe1f193..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeLink.cs
+++ /dev/null
@@ -1,331 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.IO;
-using System.Net.Sockets;
-using System.Text;
-using System.Threading;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// The loopback link to the Rust sidecar. Newline-delimited JSON, bidirectional.
- ///
- /// Threading contract, which the whole bridge depends on:
- ///
- /// * is called from the Core thread. It formats nothing, blocks on
- /// nothing, and touches no socket. It enqueues and returns. A slow, wedged, or absent
- /// sidecar cannot stall the shard.
- /// * One link thread owns the socket. It connects, drains the queue, and reconnects with
- /// backoff. A single writer keeps event ordering intact.
- /// * A reader thread parses inbound lines and hands each to the Core thread via
- /// Timer.DelayCall. The reader never touches World, Mobile, Item, or Account.
- ///
- /// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
- /// because telemetry is worth less than the shard's memory.
- ///
- public static class BridgeLink
- {
- private static readonly ConcurrentQueue _outbound = new ConcurrentQueue();
- private static readonly AutoResetEvent _wake = new AutoResetEvent(false);
-
- private static Thread _link;
- private static volatile bool _running;
- private static volatile bool _connected;
-
- // Set when the peer goes away, so the writer stops trying.
- private static volatile bool _dead;
-
- // Incremented per connection attempt. A reader from a previous connection must not be
- // able to mark a newer one dead — reader.Join can time out, and the stale thread's
- // finally block would otherwise tear down the connection that replaced it.
- private static int _epoch;
-
- ///
- /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
- /// a few seconds of buffering, not half a minute.
- ///
- private const int MaxBackoffMs = 5000;
-
- private static int _depth;
- private static long _sent, _dropped, _received, _connects, _writeErrors;
-
- /// Raised on the Core thread, one call per inbound line.
- public static event Action InboundLine;
-
- ///
- /// Raised on the Core thread after each successful connect. The sidecar may
- /// restart independently of the shard, so anything it needs to know up front has to be
- /// re-sent per connection, not once at ServerStarted.
- ///
- public static event Action Connected_Core;
-
- public static bool Connected { get { return _connected; } }
- public static int Depth { get { return Volatile.Read(ref _depth); } }
- public static long Sent { get { return Interlocked.Read(ref _sent); } }
- public static long Dropped { get { return Interlocked.Read(ref _dropped); } }
- public static long Received { get { return Interlocked.Read(ref _received); } }
- /// Total successful connections, so the first connect counts as 1.
- public static long Connects { get { return Interlocked.Read(ref _connects); } }
- public static long WriteErrors { get { return Interlocked.Read(ref _writeErrors); } }
-
- public static void Start()
- {
- if (_running)
- return;
-
- _running = true;
-
- _link = new Thread(LinkLoop)
- {
- Name = "Bridge Link",
- IsBackground = true
- };
-
- _link.Start();
- }
-
- public static void Stop()
- {
- if (!_running)
- return;
-
- _running = false;
- _wake.Set();
-
- var t = _link;
-
- if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
- Console.WriteLine("[Bridge] link thread did not stop cleanly");
-
- _link = null;
- _connected = false;
- }
-
- ///
- /// Core thread. Non-blocking. `line` must already be a complete JSON object with no
- /// embedded newline; the newline is appended by the writer as the frame delimiter.
- ///
- public static void Emit(string line)
- {
- if (!_running || line == null)
- return;
-
- // Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
- // the cap but never grows without limit.
- while (Volatile.Read(ref _depth) >= BridgeConfig.QueueCap)
- {
- string discard;
-
- if (!_outbound.TryDequeue(out discard))
- break;
-
- Interlocked.Decrement(ref _depth);
- Interlocked.Increment(ref _dropped);
- }
-
- _outbound.Enqueue(line);
- Interlocked.Increment(ref _depth);
-
- _wake.Set();
- }
-
- private static void LinkLoop()
- {
- int backoffMs = 500;
-
- while (_running)
- {
- TcpClient client = null;
- Thread reader = null;
-
- int epoch = Interlocked.Increment(ref _epoch);
-
- try
- {
- client = new TcpClient();
- client.NoDelay = true;
- client.Connect(BridgeConfig.Host, BridgeConfig.Port);
-
- var stream = client.GetStream();
- stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
-
- _dead = false;
- _connected = true;
- backoffMs = 500;
-
- Interlocked.Increment(ref _connects);
- Console.WriteLine("[Bridge] connected to {0}:{1}", BridgeConfig.Host, BridgeConfig.Port);
-
- // Building the greeting reads the world, so it must happen on the Core thread.
- Timer.DelayCall(TimeSpan.Zero, () =>
- {
- try
- {
- var handler = Connected_Core;
-
- if (handler != null)
- handler();
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] connect handler threw: {0}", ex);
- }
- });
-
- var localStream = stream;
- reader = new Thread(() => ReadLoop(localStream, epoch))
- {
- Name = "Bridge Reader",
- IsBackground = true
- };
- reader.Start();
-
- WriteLoop(stream);
- }
- catch (Exception ex)
- {
- if (_connected)
- Console.WriteLine("[Bridge] link error: {0}", ex.Message);
- }
- finally
- {
- _connected = false;
- _dead = true;
-
- try { if (client != null) client.Close(); }
- catch { }
-
- if (reader != null)
- reader.Join(TimeSpan.FromSeconds(1.0));
- }
-
- if (!_running)
- break;
-
- // Nothing is listening yet, or the sidecar restarted. Both are normal.
- Thread.Sleep(backoffMs);
- backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
- }
-
- _connected = false;
- }
-
- private static void WriteLoop(NetworkStream stream)
- {
- while (_running && !_dead)
- {
- string line;
-
- if (!_outbound.TryDequeue(out line))
- {
- _wake.WaitOne(250);
- continue;
- }
-
- Interlocked.Decrement(ref _depth);
-
- try
- {
- var bytes = Encoding.UTF8.GetBytes(line + "\n");
- stream.Write(bytes, 0, bytes.Length);
- Interlocked.Increment(ref _sent);
- }
- catch (Exception)
- {
- // The record is already off the queue. Count it and let the outer loop
- // reconnect; re-queueing risks an unbounded retry storm against a dead peer.
- Interlocked.Increment(ref _writeErrors);
- _dead = true;
- throw;
- }
- }
- }
-
- private static void ReadLoop(NetworkStream stream, int epoch)
- {
- var buffer = new byte[8192];
- var line = new StringBuilder(512);
-
- try
- {
- while (_running && !_dead)
- {
- int read = stream.Read(buffer, 0, buffer.Length);
-
- if (read <= 0)
- break; // clean EOF: the sidecar closed. Normal.
-
- for (int i = 0; i < read; i++)
- {
- char c = (char)buffer[i];
-
- if (c == '\n')
- {
- Dispatch(line.ToString());
- line.Clear();
- }
- else if (c != '\r')
- {
- line.Append(c);
-
- if (line.Length > (1 << 20))
- {
- Console.WriteLine("[Bridge] inbound line too long, dropping");
- line.Clear();
- }
- }
- }
- }
- }
- catch (IOException)
- {
- // Expected when the peer vanishes mid-read.
- }
- catch (ObjectDisposedException)
- {
- // Expected when Stop() closes the socket under us.
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] reader error: {0}", ex.Message);
- }
- finally
- {
- // Only tear down the connection this reader actually owned.
- if (Volatile.Read(ref _epoch) == epoch)
- {
- _dead = true;
- _wake.Set(); // let the writer notice and fall through to reconnect
- }
- }
- }
-
- ///
- /// Reader thread. Marshals to the Core thread. Timer.DelayCall's scheduling path is
- /// lock-protected and safe to call from any thread; the callback runs on the main loop.
- ///
- private static void Dispatch(string line)
- {
- if (line.Length == 0)
- return;
-
- Interlocked.Increment(ref _received);
-
- Timer.DelayCall(TimeSpan.Zero, () =>
- {
- try
- {
- var handler = InboundLine;
-
- if (handler != null)
- handler(line);
- }
- catch (Exception ex)
- {
- // A malformed command must never escape into a game code path.
- Console.WriteLine("[Bridge] inbound handler threw: {0}", ex);
- }
- });
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeNews.cs b/overlay/Scripts/Custom/Bridge/BridgeNews.cs
deleted file mode 100644
index 294d35d..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeNews.cs
+++ /dev/null
@@ -1,176 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Mobiles;
-using Server.Services.TownCryer;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Website news articles pushed into the modern Town Cryer News gump
- /// (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),
- /// 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
- /// content needs no gump change).
- ///
- /// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep
- /// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just
- /// the title through the existing crier say path (default on), so players hear it in-world.
- ///
- /// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall),
- /// which is required to touch the shared news list and to send crier packets.
- ///
- public static class BridgeNews
- {
- // A neutral scroll gump when the website supplies no image.
- private const int DefaultImage = 0x64E;
-
- // Website id -> the news entry we created for it, so a later remove/replace can find it.
- private static readonly Dictionary _ours =
- new Dictionary(StringComparer.Ordinal);
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- BridgeBoot.RegisterHandler("news.add", OnAdd);
- BridgeBoot.RegisterHandler("news.remove", OnRemove);
- }
-
- private static void OnAdd(Dictionary o)
- {
- var id = BridgeJson.GetString(o, "id");
-
- if (id == null)
- {
- Reply("news.error", null, "missing id");
- return;
- }
-
- var list = TownCryerSystem.NewsEntries;
- if (list == null)
- {
- Reply("news.error", id, "town cryer unavailable");
- return;
- }
-
- var title = BridgeJson.GetString(o, "title");
- if (String.IsNullOrEmpty(title))
- {
- Reply("news.error", id, "missing title");
- return;
- }
-
- var body = BridgeJson.GetString(o, "body") ?? "";
- var url = BridgeJson.GetString(o, "url");
- int image = BridgeJson.GetInt(o, "image", DefaultImage);
-
- // announce defaults to true (proclaim the title in-world); "announce":false suppresses it.
- bool announce = true;
- object rawAnnounce;
- if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool)
- announce = (bool)rawAnnounce;
-
- if (title.Length > BridgeConfig.NewsMaxTitleLength)
- title = title.Substring(0, BridgeConfig.NewsMaxTitleLength);
- if (body.Length > BridgeConfig.NewsMaxBodyLength)
- body = body.Substring(0, BridgeConfig.NewsMaxBodyLength);
-
- try
- {
- // Replace an existing id in place: drop the old entry first.
- TownCryerNewsEntry old;
- if (_ours.TryGetValue(id, out old) && old != null)
- {
- list.Remove(old);
- _ours.Remove(id);
- }
- else if (_ours.Count >= BridgeConfig.NewsMaxExternal)
- {
- Reply("news.error", id, "too many news entries");
- return;
- }
-
- var entry = new TownCryerNewsEntry(
- new TextDefinition(title),
- new TextDefinition(body),
- image,
- null,
- url);
-
- list.Insert(0, entry); // newest first, as the gump reads top-down
- _ours[id] = entry;
-
- if (announce)
- Announce(title);
-
- Reply("news.ok", id, null);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message);
- Reply("news.error", id, "internal error");
- }
- }
-
- private static void OnRemove(Dictionary o)
- {
- var id = BridgeJson.GetString(o, "id");
-
- if (id == null)
- {
- Reply("news.error", null, "missing id");
- return;
- }
-
- TownCryerNewsEntry entry;
- if (!_ours.TryGetValue(id, out entry))
- {
- Reply("news.error", id, "unknown id");
- return;
- }
-
- _ours.Remove(id);
-
- try
- {
- var list = TownCryerSystem.NewsEntries;
- if (list != null && entry != null)
- list.Remove(entry);
-
- Reply("news.ok", id, null);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message);
- Reply("news.error", id, "internal error");
- }
- }
-
- /// Proclaims a single line — the article title — through the town criers.
- private static void Announce(string title)
- {
- try
- {
- GlobalTownCrierEntryList.Instance.AddEntry(
- new[] { title },
- TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec));
- }
- catch (Exception ex)
- {
- // A failed proclamation must not fail the news add — the article is already posted.
- Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message);
- }
- }
-
- private static void Reply(string kind, string id, string reason)
- {
- var sb = BridgeJson.Begin(kind);
- if (id != null) sb.Str("id", id);
- if (reason != null) sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgePages.cs b/overlay/Scripts/Custom/Bridge/BridgePages.cs
deleted file mode 100644
index ff3a541..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgePages.cs
+++ /dev/null
@@ -1,420 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-
-using Server.Accounting;
-using Server.Engines.Help;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// The in-game help-page (support ticket) queue, surfaced to the website.
- ///
- /// A player who uses the Help button creates a — sender, message,
- /// type, location, and (once a staffer claims it) a handler. The queue lives in memory with
- /// no EventSink, so — like the sweeps in — it is polled and diffed:
- /// a page appearing emits page.new, one leaving emits page.closed, and a
- /// handled-state change emits page.updated. The whole open queue is also available on
- /// demand via the pages.snapshot request (the backfill a dashboard uses on connect).
- ///
- /// A page is keyed by its sender's serial: the queue enforces one page per sender
- /// (PageQueue.Contains), so the sender serial is a stable page id.
- ///
- /// Inbound page.respond delivers a message to the player exactly as an in-game staff
- /// response does (online: a gump now; offline: queued for next login), optionally closing the
- /// page; page.close just removes it. Both run on the Core thread.
- ///
- public static class BridgePages
- {
- private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
-
- private static Timer _timer;
- private static long _sweeps, _new, _closed, _updated;
-
- private struct Seen
- {
- public long SentMs;
- public bool Handled;
- }
-
- // sender serial -> last-seen page identity. Core-thread only.
- private static readonly Dictionary _seen = new Dictionary();
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot);
- BridgeBoot.RegisterHandler("page.respond", OnRespond);
- BridgeBoot.RegisterHandler("page.close", OnClose);
-
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- Baseline();
- Rearm();
- }
-
- /// Stops and recreates the poll timer from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- if (_timer != null)
- {
- _timer.Stop();
- _timer = null;
- }
-
- var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds);
- _timer = Timer.DelayCall(iv, iv, Sweep);
- }
-
- public static string Status()
- {
- return String.Format(
- "pages(sweeps={0} new={1} closed={2} updated={3} open={4})",
- _sweeps, _new, _closed, _updated, _seen.Count);
- }
-
- /// Seeds _seen from the current queue without emitting, so a restart/reload does not
- /// re-announce pages already open.
- private static void Baseline()
- {
- _seen.Clear();
-
- foreach (PageEntry e in PageQueue.List)
- {
- if (e == null || e.Sender == null)
- continue;
-
- _seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null };
- }
- }
-
- // ---- poll ----
-
- private static void Sweep()
- {
- try
- {
- _sweeps++;
-
- var cur = new Dictionary();
-
- foreach (PageEntry e in PageQueue.List)
- {
- if (e == null || e.Sender == null)
- continue;
-
- cur[e.Sender.Serial.Value] = e;
- }
-
- // Closed: keys in _seen no longer present.
- if (_seen.Count > 0)
- {
- List gone = null;
-
- foreach (var kv in _seen)
- {
- if (!cur.ContainsKey(kv.Key))
- {
- if (gone == null)
- gone = new List();
- gone.Add(kv.Key);
- }
- }
-
- if (gone != null)
- {
- foreach (var id in gone)
- {
- EmitClosed(id);
- _seen.Remove(id);
- }
- }
- }
-
- // New / replaced / handled-state changed.
- foreach (var kv in cur)
- {
- var e = kv.Value;
- long sentMs = ToMs(e.Sent);
- bool handled = e.Handler != null;
-
- Seen prev;
- if (!_seen.TryGetValue(kv.Key, out prev))
- {
- EmitNew(e);
- }
- else if (prev.SentMs != sentMs)
- {
- // Same sender, different page (they cancelled and re-paged within a tick).
- EmitClosed(kv.Key);
- EmitNew(e);
- }
- else if (prev.Handled != handled)
- {
- EmitUpdated(e);
- }
-
- _seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled };
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message);
- }
- }
-
- // ---- outbound ----
-
- private static void EmitNew(PageEntry e)
- {
- _new++;
- var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e));
- AppendPageTail(sb, e);
- BridgeLink.Emit(sb.End());
- }
-
- private static void EmitUpdated(PageEntry e)
- {
- _updated++;
- var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e));
- AppendPageTail(sb, e);
- BridgeLink.Emit(sb.End());
- }
-
- private static void EmitClosed(int serial)
- {
- _closed++;
- BridgeLink.Emit(BridgeJson.Begin("page.closed")
- .Str("pageId", "0x" + serial.ToString("X"))
- .End());
- }
-
- private static void OnSnapshot(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
-
- var sb = BridgeJson.Begin("pages.list");
- if (reqId != null)
- sb.Str("reqId", reqId);
-
- sb.Append(",\"pages\":[");
-
- bool first = true;
- foreach (PageEntry e in PageQueue.List)
- {
- if (e == null || e.Sender == null)
- continue;
-
- if (!first)
- sb.Append(',');
- first = false;
-
- sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"');
- AppendPageTail(sb, e);
- sb.Append('}');
- }
-
- sb.Append(']');
- BridgeLink.Emit(sb.End());
- }
-
- /// Appends every page field except the opening pageId, each comma-prefixed, so it
- /// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array).
- private static void AppendPageTail(StringBuilder sb, PageEntry e)
- {
- sb.Append(",\"sender\":");
- WriteSender(sb, e.Sender);
-
- sb.Str("type", e.Type.ToString());
- sb.Str("message", e.Message ?? "");
- sb.Str("map", e.PageMap == null ? null : e.PageMap.Name);
- sb.Num("x", e.PageLocation.X);
- sb.Num("y", e.PageLocation.Y);
- sb.Num("z", e.PageLocation.Z);
- sb.Num("sentMs", ToMs(e.Sent));
- sb.Bool("handled", e.Handler != null);
-
- if (e.Handler != null)
- sb.Str("handler", e.Handler.Name);
- }
-
- private static void WriteSender(StringBuilder sb, Mobile m)
- {
- if (m == null)
- {
- sb.Append("null");
- return;
- }
-
- sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
- sb.Append(",\"name\":");
- BridgeJson.Escape(sb, m.Name ?? "");
-
- var acct = m.Account as Account;
- if (acct != null)
- {
- sb.Append(",\"acct\":");
- BridgeJson.Escape(sb, acct.Username);
-
- var webId = BridgeAccountLink.WebIdFor(acct);
- if (webId != null)
- {
- sb.Append(",\"webId\":");
- BridgeJson.Escape(sb, webId);
- }
- }
-
- sb.Append('}');
- }
-
- // ---- inbound ----
-
- /// page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
- /// player and optionally closes the page.
- private static void OnRespond(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var pageId = BridgeJson.GetString(o, "pageId");
- var message = BridgeJson.GetString(o, "message");
- bool close = GetBool(o, "close");
-
- if (String.IsNullOrEmpty(message))
- {
- Err(reqId, "respond", pageId, "missing message");
- return;
- }
-
- var e = Find(pageId);
- if (e == null)
- {
- Err(reqId, "respond", pageId, "unknown page");
- return;
- }
-
- try
- {
- // Same delivery as an in-game staff response: a null handler shows as "Staff".
- // ResponseEntry queues for an offline sender; SendGump delivers now if online.
- var re = new ResponseEntry(e.Sender, null, message);
- re.SendGump();
-
- if (close)
- PageQueue.Remove(e);
-
- Ok(reqId, "respond", pageId, close);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message);
- Err(reqId, "respond", pageId, "internal error");
- }
- }
-
- /// page.close {reqId, pageId}. Removes the page from the queue.
- private static void OnClose(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var pageId = BridgeJson.GetString(o, "pageId");
-
- var e = Find(pageId);
- if (e == null)
- {
- Err(reqId, "close", pageId, "unknown page");
- return;
- }
-
- try
- {
- PageQueue.Remove(e);
- Ok(reqId, "close", pageId, true);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message);
- Err(reqId, "close", pageId, "internal error");
- }
- }
-
- private static void Ok(string reqId, string action, string pageId, bool closed)
- {
- var sb = BridgeJson.Begin("page.ok");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action);
- if (pageId != null) sb.Str("pageId", pageId);
- sb.Bool("closed", closed);
- BridgeLink.Emit(sb.End());
- }
-
- private static void Err(string reqId, string action, string pageId, string reason)
- {
- var sb = BridgeJson.Begin("page.error");
- if (reqId != null) sb.Str("reqId", reqId);
- sb.Str("action", action);
- if (pageId != null) sb.Str("pageId", pageId);
- sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- helpers ----
-
- private static PageEntry Find(string pageId)
- {
- int serial;
- if (!TryParseSerial(pageId, out serial))
- return null;
-
- foreach (PageEntry e in PageQueue.List)
- {
- if (e != null && e.Sender != null && e.Sender.Serial.Value == serial)
- return e;
- }
-
- return null;
- }
-
- private static string PageId(PageEntry e)
- {
- return "0x" + e.Sender.Serial.Value.ToString("X");
- }
-
- private static long ToMs(DateTime dt)
- {
- return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds;
- }
-
- private static bool GetBool(Dictionary o, string key)
- {
- object v;
- if (o != null && o.TryGetValue(key, out v) && v is bool)
- return (bool)v;
- return false;
- }
-
- private static bool TryParseSerial(string s, out int value)
- {
- value = 0;
- if (String.IsNullOrEmpty(s))
- return false;
-
- try
- {
- s = s.Trim();
- if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
- value = Convert.ToInt32(s.Substring(2), 16);
- else
- value = Convert.ToInt32(s, 10);
-
- return true;
- }
- catch
- {
- return false;
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgePresence.cs b/overlay/Scripts/Custom/Bridge/BridgePresence.cs
deleted file mode 100644
index e164c2b..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgePresence.cs
+++ /dev/null
@@ -1,203 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Mobiles;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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
- /// on a sweep but only when it changes, so the site has a live "N online"
- /// plus a change history without a firehose of identical frames.
- /// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap
- /// per-player movement signal PLAN.md §5.6 recommends over Movement.
- ///
- /// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the
- /// same population the vitals sweep already walks; counting them by map and region is a handful
- /// of field reads. region.enter is filtered to players.
- ///
- public static class BridgePresence
- {
- private static Timer _timer;
-
- // Signature of the last-emitted snapshot, so an unchanged population emits nothing.
- private static string _lastSig;
-
- private static long _sweeps, _emitted, _regionEnters;
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.OnEnterRegion += OnEnterRegion;
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- // Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the
- // current population within one sweep.
- BridgeLink.Connected_Core += OnConnected;
- Rearm();
- }
-
- private static void OnConnected()
- {
- _lastSig = null;
- }
-
- /// Stops and recreates the timer from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- Stop();
-
- _timer = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds),
- PresenceSweep);
- }
-
- public static void Stop()
- {
- if (_timer != null) { _timer.Stop(); _timer = null; }
- }
-
- public static string Status()
- {
- return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})",
- _sweeps, _emitted, _regionEnters);
- }
-
- /// Runs one sweep now. Wired into `[bridge sweepnow`.
- public static void SweepOnce()
- {
- PresenceSweep();
- }
-
- private static void PresenceSweep()
- {
- try
- {
- _sweeps++;
-
- if (!BridgeLink.Connected)
- return; // nothing is listening; do not fill the queue with perishable snapshots
-
- int total = 0;
- var byFacet = new SortedDictionary(StringComparer.Ordinal);
- var byRegion = new SortedDictionary(StringComparer.Ordinal);
-
- foreach (var m in World.Mobiles.Values)
- {
- var pm = m as PlayerMobile;
-
- if (pm == null || pm.NetState == null || pm.Deleted)
- continue;
-
- total++;
-
- var facet = pm.Map == null ? "Internal" : pm.Map.Name;
- Bump(byFacet, facet);
-
- var region = pm.Region;
- var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name;
- Bump(byRegion, regionName);
- }
-
- var sig = Signature(total, byFacet, byRegion);
- if (sig == _lastSig)
- return; // population unchanged since last emit
-
- _lastSig = sig;
- BridgeLink.Emit(WriteOnline(total, byFacet, byRegion));
- _emitted++;
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message);
- }
- }
-
- private static void Bump(IDictionary map, string key)
- {
- int n;
- map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
- }
-
- private static string Signature(int total, SortedDictionary byFacet, SortedDictionary byRegion)
- {
- var sb = new System.Text.StringBuilder();
- sb.Append(total);
- foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
- sb.Append('#');
- foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value);
- return sb.ToString();
- }
-
- private static string WriteOnline(int total, SortedDictionary byFacet, SortedDictionary byRegion)
- {
- var sb = BridgeJson.Begin("presence.online").Num("count", total);
-
- WriteCounts(sb, "byFacet", byFacet);
- WriteCounts(sb, "byRegion", byRegion);
-
- return sb.End();
- }
-
- /// Writes a nested object of {name: count} pairs.
- private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary counts)
- {
- sb.Append(",\"").Append(field).Append("\":{");
-
- bool first = true;
- foreach (var kv in counts)
- {
- if (!first)
- sb.Append(',');
- first = false;
-
- BridgeJson.Escape(sb, kv.Key);
- sb.Append(':').Append(kv.Value);
- }
-
- sb.Append('}');
- }
-
- // ---- real-time region transitions ----
-
- private static void OnEnterRegion(OnEnterRegionEventArgs e)
- {
- try
- {
- if (e == null || e.From == null || !e.From.Player)
- return;
-
- var from = e.OldRegion;
- var to = e.NewRegion;
-
- // Only meaningful when the named region actually changed.
- var fromName = from == null ? null : from.Name;
- var toName = to == null ? null : to.Name;
- if (String.Equals(fromName, toName, StringComparison.Ordinal))
- return;
-
- var sb = BridgeJson.Begin("region.enter")
- .Str("from", fromName)
- .Str("to", toName)
- .Str("map", e.From.Map == null ? null : e.From.Map.Name);
-
- sb.Actor("who", e.From);
-
- BridgeLink.Emit(sb.End());
- _regionEnters++;
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message);
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
deleted file mode 100644
index dd3a30b..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
+++ /dev/null
@@ -1,291 +0,0 @@
-using System;
-using System.Text;
-
-using Server.Accounting;
-using Server.Items;
-using Server.Mobiles;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Builds the heavy read-models the website consumes: a full character profile, an account
- /// roster, and a player's vendor holdings. All read live Mobile/Item state, so all must run
- /// on the Core thread — which the inbound dispatch guarantees (BridgeLink marshals every
- /// inbound line through Timer.DelayCall before a handler sees it).
- ///
- /// 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
- /// a sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
- ///
- public static class BridgeProfile
- {
- private static readonly AosAttribute[] AllAttrs =
- (AosAttribute[])Enum.GetValues(typeof(AosAttribute));
-
- private static readonly AosWeaponAttribute[] AllWeaponAttrs =
- (AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
-
- private static readonly AosArmorAttribute[] AllArmorAttrs =
- (AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
-
- // ---- full profile ----
-
- public static string BuildProfile(PlayerMobile m, string reqId)
- {
- var sb = BridgeJson.Begin("char.profile");
-
- if (reqId != null)
- sb.Str("reqId", reqId);
-
- sb.Ser("serial", m.Serial);
- sb.Str("name", m.Name);
- sb.Str("title", m.Title);
- sb.Num("body", m.Body.BodyID);
- sb.Num("hue", m.Hue);
- sb.Bool("online", m.NetState != null);
-
- var acct = m.Account as Account;
- if (acct != null)
- sb.Str("acct", acct.Username);
-
- // stats
- sb.Append(",\"stats\":{");
- sb.Append("\"str\":").Append(m.Str).Append(",\"dex\":").Append(m.Dex).Append(",\"int\":").Append(m.Int);
- sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
- sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
- sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
- sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
- sb.Append(",\"luck\":").Append(m.Luck);
- sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
- sb.Append(",\"fire\":").Append(m.FireResistance);
- sb.Append(",\"cold\":").Append(m.ColdResistance);
- sb.Append(",\"pois\":").Append(m.PoisonResistance);
- sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
-
- // skills: trained only (Base > 0), to avoid ~50 zeroes per character
- sb.Append(",\"skills\":[");
- bool first = true;
- for (int i = 0; i < m.Skills.Length; i++)
- {
- var s = m.Skills[i];
-
- if (s == null || s.Base <= 0.0)
- continue;
-
- if (!first) sb.Append(',');
- first = false;
-
- sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
- sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
- sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
- sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
- sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
- }
- sb.Append(']');
-
- // 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\":[");
- first = true;
- foreach (var item in m.Items)
- {
- if (item == null || !IsGearLayer(item.Layer))
- continue;
-
- if (!first) sb.Append(',');
- first = false;
-
- WriteItem(sb, item);
- }
- sb.Append(']');
-
- WriteTitles(sb, m);
-
- return sb.End();
- }
-
- ///
- /// 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
- /// 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.
- ///
- private static void WriteTitles(StringBuilder sb, PlayerMobile m)
- {
- sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
-
- var fameKarma = m.FameKarmaTitle;
- if (!String.IsNullOrEmpty(fameKarma))
- {
- sb.Append(",\"fameKarma\":");
- BridgeJson.Escape(sb, fameKarma);
- }
-
- var skill = m.PaperdollSkillTitle;
- if (!String.IsNullOrEmpty(skill))
- {
- sb.Append(",\"skill\":");
- BridgeJson.Escape(sb, skill);
- }
-
- sb.Append(",\"reward\":[");
- var rewards = m.RewardTitles;
- if (rewards != null)
- {
- bool first = true;
- for (int i = 0; i < rewards.Count; i++)
- {
- var r = rewards[i];
- if (r == null)
- continue;
-
- if (!first) sb.Append(',');
- first = false;
-
- BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
- }
- }
- sb.Append("]}");
- }
-
- private static bool IsGearLayer(Layer layer)
- {
- switch (layer)
- {
- case Layer.Backpack:
- case Layer.Bank:
- case Layer.Hair:
- case Layer.FacialHair:
- case Layer.Mount:
- case Layer.Invalid:
- return false;
- default:
- return true;
- }
- }
-
- private static void WriteItem(StringBuilder sb, Item item)
- {
- sb.Append("{");
- sb.Append("\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
- sb.Append(",\"layer\":\"").Append(item.Layer).Append('"');
- sb.Append(",\"itemId\":").Append(item.ItemID);
- sb.Append(",\"hue\":").Append(item.Hue);
- sb.Append(",\"cliloc\":").Append(item.LabelNumber);
-
- if (item.Name != null)
- {
- sb.Append(",\"name\":");
- BridgeJson.Escape(sb, item.Name);
- }
-
- var weapon = item as BaseWeapon;
- var armor = item as BaseArmor;
-
- if (weapon != null)
- {
- sb.Append(",\"weapon\":{\"minDamage\":").Append(weapon.MinDamage);
- sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage).Append('}');
- }
- else if (armor != null)
- {
- sb.Append(",\"armor\":{\"baseRating\":").Append(armor.BaseArmorRating).Append('}');
- }
-
- // flattened union of non-zero mods across every attribute bag
- sb.Append(",\"mods\":{");
- bool first = true;
-
- if (weapon != null)
- {
- WriteAttrs(sb, weapon.Attributes, ref first);
- WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref first);
- }
- else if (armor != null)
- {
- WriteAttrs(sb, armor.Attributes, ref first);
- WriteArmorAttrs(sb, armor.ArmorAttributes, ref first);
- }
-
- sb.Append("}}");
- }
-
- private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
- {
- if (a == null) return;
-
- for (int i = 0; i < AllAttrs.Length; i++)
- {
- int v = a[AllAttrs[i]];
- if (v == 0) continue;
- if (!first) sb.Append(','); first = false;
- sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
- }
- }
-
- private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
- {
- if (a == null) return;
-
- for (int i = 0; i < AllWeaponAttrs.Length; i++)
- {
- int v = a[AllWeaponAttrs[i]];
- if (v == 0) continue;
- if (!first) sb.Append(','); first = false;
- sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
- }
- }
-
- private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
- {
- if (a == null) return;
-
- for (int i = 0; i < AllArmorAttrs.Length; i++)
- {
- int v = a[AllArmorAttrs[i]];
- if (v == 0) continue;
- if (!first) sb.Append(','); first = false;
- sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
- }
- }
-
- // ---- account roster ----
-
- ///
- /// Light per-character summary for an account. Offline characters are included: a
- /// logged-off mobile stays resident (World.Mobiles) until Delete, so its roster entry is
- /// always available.
- ///
- public static string BuildRoster(Account acct, string reqId)
- {
- var sb = BridgeJson.Begin("account.roster");
-
- if (reqId != null)
- sb.Str("reqId", reqId);
-
- sb.Str("acct", acct.Username);
- sb.Append(",\"chars\":[");
-
- bool first = true;
- for (int i = 0; i < acct.Length; i++)
- {
- var m = acct[i];
- if (m == null)
- continue;
-
- if (!first) sb.Append(',');
- first = false;
-
- sb.Append("{\"slot\":").Append(i);
- sb.Append(",\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
- sb.Append(",\"name\":");
- BridgeJson.Escape(sb, m.Name ?? "");
- sb.Append(",\"body\":").Append(m.Body.BodyID);
- sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
- sb.Append('}');
- }
- sb.Append(']');
-
- return sb.End();
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeRequests.cs b/overlay/Scripts/Custom/Bridge/BridgeRequests.cs
deleted file mode 100644
index e7f277d..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeRequests.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Accounting;
-using Server.Mobiles;
-using Server.Multis;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Inbound request/response. The sidecar asks; the shard answers. Every handler runs on the
- /// Core thread (BridgeBoot dispatches inbound lines through Timer.DelayCall first), so all of
- /// these may read live world state freely.
- ///
- /// A request carries an optional "reqId" the shard echoes back, so the sidecar can correlate
- /// the reply with the request it sent. A malformed or unresolvable request gets a
- /// "bridge.error" reply rather than silence, so the website can show a real failure instead
- /// of hanging.
- ///
- public static class BridgeRequests
- {
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- BridgeBoot.RegisterHandler("char.request", OnCharRequest);
- BridgeBoot.RegisterHandler("account.roster", OnRosterRequest);
- BridgeBoot.RegisterHandler("vendor.snapshot", OnVendorSnapshotRequest);
- }
-
- private static void Fail(string reqId, string reason)
- {
- var sb = BridgeJson.Begin("bridge.error");
- if (reqId != null)
- sb.Str("reqId", reqId);
- sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
-
- // ---- char.request ----
-
- ///
- /// Resolve a character by serial, or by account + slot, and reply with a full profile.
- /// Works for offline characters too: a logged-off mobile is still resident.
- ///
- private static void OnCharRequest(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
-
- PlayerMobile pm = null;
-
- var serialStr = BridgeJson.GetString(o, "serial");
- if (serialStr != null)
- {
- pm = ResolveSerial(serialStr) as PlayerMobile;
-
- if (pm == null)
- {
- Fail(reqId, "no player with serial " + serialStr);
- return;
- }
- }
- else
- {
- var acctName = BridgeJson.GetString(o, "account");
- var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
-
- if (acct == null)
- {
- Fail(reqId, "unknown account");
- return;
- }
-
- int slot = BridgeJson.GetInt(o, "slot", 0);
-
- if (slot < 0 || slot >= acct.Length)
- {
- Fail(reqId, "slot out of range");
- return;
- }
-
- pm = acct[slot] as PlayerMobile;
-
- if (pm == null)
- {
- Fail(reqId, "no character in slot " + slot);
- return;
- }
- }
-
- BridgeLink.Emit(BridgeProfile.BuildProfile(pm, reqId));
- }
-
- // ---- account.roster ----
-
- private static void OnRosterRequest(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var acctName = BridgeJson.GetString(o, "account");
- var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
-
- if (acct == null)
- {
- Fail(reqId, "unknown account");
- return;
- }
-
- BridgeLink.Emit(BridgeProfile.BuildRoster(acct, reqId));
- }
-
- // ---- vendor.snapshot ----
-
- ///
- /// Every player vendor owned by any character on an account, with its held gold and
- /// priced listings. Enumerates PlayerVendor.PlayerVendors and matches by owner account.
- ///
- private static void OnVendorSnapshotRequest(Dictionary o)
- {
- var reqId = BridgeJson.GetString(o, "reqId");
- var acctName = BridgeJson.GetString(o, "account");
- var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
-
- if (acct == null)
- {
- Fail(reqId, "unknown account");
- return;
- }
-
- var sb = BridgeJson.Begin("vendor.snapshot");
- if (reqId != null)
- sb.Str("reqId", reqId);
- sb.Str("acct", acct.Username);
- sb.Append(",\"vendors\":[");
-
- bool firstVendor = true;
- var all = PlayerVendor.PlayerVendors;
-
- if (all != null)
- {
- foreach (var v in all)
- {
- if (v == null || v.Deleted || v.Owner == null)
- continue;
-
- if (!(v.Owner.Account is Account ownerAcct) || ownerAcct != acct)
- continue;
-
- if (!firstVendor) sb.Append(',');
- firstVendor = false;
-
- sb.Append("{\"serial\":\"0x").Append(v.Serial.Value.ToString("X")).Append('"');
- sb.Append(",\"shopName\":");
- BridgeJson.Escape(sb, v.ShopName ?? "");
- sb.Append(",\"holdGold\":").Append(v.HoldGold);
- sb.Append(",\"ownerSerial\":\"0x").Append(v.Owner.Serial.Value.ToString("X")).Append('"');
-
- var house = v.Map;
- sb.Append(",\"map\":");
- BridgeJson.Escape(sb, v.Map == null ? "" : v.Map.Name);
- sb.Append(",\"x\":").Append(v.X).Append(",\"y\":").Append(v.Y);
-
- sb.Append(",\"listings\":[");
- bool firstItem = true;
- var pack = v.Backpack;
-
- if (pack != null)
- {
- foreach (var item in pack.Items)
- {
- var vi = v.GetVendorItem(item);
- if (vi == null)
- continue;
-
- if (!firstItem) sb.Append(',');
- firstItem = false;
-
- sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
- sb.Append(",\"itemId\":").Append(item.ItemID);
- sb.Append(",\"amount\":").Append(item.Amount);
- sb.Append(",\"price\":").Append(vi.Price);
- sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false").Append('}');
- }
- }
- sb.Append("]}");
- }
- }
-
- sb.Append(']');
- BridgeLink.Emit(sb.End());
- }
-
- // ---- helpers ----
-
- private static Mobile ResolveSerial(string serialStr)
- {
- try
- {
- var s = serialStr.Trim();
- int value;
-
- if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
- value = Convert.ToInt32(s.Substring(2), 16);
- else
- value = Convert.ToInt32(s, 10);
-
- return World.FindMobile(value);
- }
- catch
- {
- return null;
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs
deleted file mode 100644
index 48b756d..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs
+++ /dev/null
@@ -1,218 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-
-using Server.Guilds;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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
- /// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
- /// exactly like , the roster is polled: enumerate BaseGuild.List each
- /// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
- /// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
- ///
- /// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
- /// so joined" feed does not wait for the next sweep. A membership change also moves the board
- /// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
- /// the next `guild.update`; per-member leave events would need a core tap and are a later
- /// refinement (§10.1).
- ///
- /// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
- /// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
- /// guild, would look like every guild being created at once.
- ///
- public static class BridgeSocial
- {
- private static Timer _timer;
-
- // guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
- // was cleared on reconnect), so its next sweep counts as a change.
- private static readonly Dictionary _last = new Dictionary();
-
- private static long _sweeps, _emitted, _removed, _joins;
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.JoinGuild += OnJoinGuild;
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- BridgeLink.Connected_Core += OnConnected;
- Rearm();
- }
-
- private static void OnConnected()
- {
- _last.Clear();
- }
-
- /// Stops and recreates the timer from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- Stop();
-
- _timer = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
- GuildSweep);
- }
-
- public static void Stop()
- {
- if (_timer != null) { _timer.Stop(); _timer = null; }
- }
-
- public static string Status()
- {
- return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
- _sweeps, _emitted, _removed, _joins, _last.Count);
- }
-
- /// Runs one sweep now. Wired into `[bridge sweepnow`.
- public static void SweepOnce()
- {
- GuildSweep();
- }
-
- private static void GuildSweep()
- {
- try
- {
- _sweeps++;
-
- if (!BridgeLink.Connected)
- return; // nothing is listening; do not fill the queue with perishable snapshots
-
- var seen = new HashSet();
-
- foreach (var bg in BaseGuild.List.Values)
- {
- var g = bg as Guild;
-
- // Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
- // and treating them as absent lets the "gone" pass below emit guild.remove.
- if (g == null || g.Disbanded)
- continue;
-
- seen.Add(g.Id);
-
- var sig = Signature(g);
-
- string prior;
- if (_last.TryGetValue(g.Id, out prior) && prior == sig)
- continue; // unchanged since last emit
-
- _last[g.Id] = sig;
- BridgeLink.Emit(WriteGuild(g));
- _emitted++;
- }
-
- // Anything tracked last sweep but not seen now has disbanded or been removed.
- var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
- foreach (var id in gone)
- {
- _last.Remove(id);
- BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
- _removed++;
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
- }
- }
-
- // The volatile fields that define a meaningful change: name, abbreviation, leader, member
- // count, the member set (order-independent serial sum), and alliance.
- private static string Signature(Guild g)
- {
- long memberSum = 0;
- int count = 0;
-
- var members = g.Members;
- if (members != null)
- {
- for (int i = 0; i < members.Count; i++)
- {
- var m = members[i];
- if (m == null)
- continue;
- count++;
- unchecked { memberSum += (uint)m.Serial.Value; }
- }
- }
-
- var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
-
- return String.Concat(
- g.Name ?? "", "|",
- g.Abbreviation ?? "", "|",
- leaderSerial.ToString(), "|",
- count.ToString(), "|",
- memberSum.ToString(), "|",
- g.Alliance == null ? "" : (g.AllianceName ?? ""));
- }
-
- private static string WriteGuild(Guild g)
- {
- int online = 0, count = 0;
- var members = g.Members;
- if (members != null)
- {
- for (int i = 0; i < members.Count; i++)
- {
- var m = members[i];
- if (m == null)
- continue;
- count++;
- if (m.NetState != null)
- online++;
- }
- }
-
- var sb = BridgeJson.Begin("guild.update")
- .Num("id", g.Id)
- .Str("name", g.Name)
- .Str("abbr", g.Abbreviation)
- .Num("members", count)
- .Num("online", online)
- .Str("alliance", g.Alliance == null ? null : g.AllianceName);
-
- sb.Actor("leader", g.Leader);
-
- return sb.End();
- }
-
- // ---- real-time join ----
-
- private static void OnJoinGuild(JoinGuildEventArgs e)
- {
- try
- {
- if (e == null || e.Mobile == null)
- return;
-
- var g = e.Guild as Guild;
-
- var sb = BridgeJson.Begin("guild.join");
- if (g != null)
- sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
- sb.Actor("who", e.Mobile);
- BridgeLink.Emit(sb.End());
- _joins++;
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
- }
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs b/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs
deleted file mode 100644
index 0de3fb0..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs
+++ /dev/null
@@ -1,279 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-
-using Server.Accounting;
-using Server.Mobiles;
-using Server.Multis;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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
- /// 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.
- ///
- /// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
- /// mid-save simply happens a few seconds later. That is fine for all three.
- ///
- public static class BridgeSweeps
- {
- private static Timer _vitals, _decay, _economy;
-
- // Last-known decay level per house. In memory, rebuilt from a silent baseline on
- // ServerStarted, so a restart does not re-announce every house's current stage.
- private static readonly Dictionary _decayState =
- new Dictionary();
-
- private static bool _baselined;
-
- private static long _vitalsSweeps, _vitalsEmitted, _decaySweeps, _decayTransitions, _economySweeps;
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.ServerStarted += OnServerStarted;
- }
-
- private static void OnServerStarted()
- {
- BaselineDecay();
- Rearm();
- }
-
- /// Stops and recreates the timers from current config. Called by `[bridge reload`.
- public static void Rearm()
- {
- Stop();
-
- _vitals = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.StatSweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.StatSweepSeconds),
- VitalsSweep);
-
- _decay = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.DecaySweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.DecaySweepSeconds),
- DecaySweep);
-
- _economy = Timer.DelayCall(
- TimeSpan.FromSeconds(BridgeConfig.EconomySweepSeconds),
- TimeSpan.FromSeconds(BridgeConfig.EconomySweepSeconds),
- EconomySweep);
- }
-
- public static void Stop()
- {
- if (_vitals != null) { _vitals.Stop(); _vitals = null; }
- if (_decay != null) { _decay.Stop(); _decay = null; }
- if (_economy != null) { _economy.Stop(); _economy = null; }
- }
-
- public static string Status()
- {
- return String.Format(
- "vitals(sweeps={0} emitted={1}) decay(sweeps={2} transitions={3} tracked={4}) economy(sweeps={5})",
- _vitalsSweeps, _vitalsEmitted, _decaySweeps, _decayTransitions, _decayState.Count, _economySweeps);
- }
-
- // ---- vitals ----
-
- ///
- /// Online players only. Vitals are small and volatile; the sidecar diffs successive
- /// snapshots and forwards only changes. Offline characters do not move, so there is
- /// nothing to sweep — their state is served on demand as a full profile instead.
- ///
- private static void VitalsSweep()
- {
- try
- {
- _vitalsSweeps++;
-
- if (!BridgeLink.Connected)
- return; // nothing is listening; do not fill the queue with perishable snapshots
-
- foreach (var m in World.Mobiles.Values)
- {
- var pm = m as PlayerMobile;
-
- if (pm == null || pm.NetState == null || pm.Deleted)
- continue;
-
- BridgeLink.Emit(WriteVitals(pm));
- _vitalsEmitted++;
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] vitals sweep threw: {0}", ex.Message);
- }
- }
-
- private static string WriteVitals(PlayerMobile m)
- {
- return BridgeJson.Begin("char.vitals")
- .Ser("serial", m.Serial)
- .Num("hits", m.Hits).Num("hitsMax", m.HitsMax)
- .Num("mana", m.Mana).Num("manaMax", m.ManaMax)
- .Num("stam", m.Stam).Num("stamMax", m.StamMax)
- .Num("str", m.Str).Num("dex", m.Dex).Num("int", m.Int)
- .Str("map", m.Map == null ? null : m.Map.Name)
- .Num("x", m.X).Num("y", m.Y)
- .End();
- }
-
- // ---- house decay ----
-
- ///
- /// Populates the last-known level for every house without emitting. Without this, the
- /// first sweep after a restart would report every house as a fresh transition.
- ///
- private static void BaselineDecay()
- {
- try
- {
- _decayState.Clear();
-
- foreach (var house in BaseHouse.AllHouses)
- {
- if (house == null || house.Deleted)
- continue;
-
- _decayState[house.Serial] = house.DecayLevel;
- }
-
- _baselined = true;
- Console.WriteLine("[Bridge] decay baseline: {0} houses", _decayState.Count);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] decay baseline threw: {0}", ex.Message);
- }
- }
-
- private static void DecaySweep()
- {
- try
- {
- _decaySweeps++;
-
- if (!_baselined)
- BaselineDecay();
-
- foreach (var house in BaseHouse.AllHouses)
- {
- if (house == null || house.Deleted)
- continue;
-
- var level = house.DecayLevel; // computed getter — read once
- var serial = house.Serial;
-
- DecayLevel prior;
- bool known = _decayState.TryGetValue(serial, out prior);
-
- if (known && prior == level)
- continue;
-
- _decayState[serial] = level;
-
- if (!known)
- continue; // a house that appeared since baseline; record, do not announce
-
- _decayTransitions++;
-
- if (BridgeLink.Connected)
- BridgeLink.Emit(WriteDecay(house, prior, level));
- }
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] decay sweep threw: {0}", ex.Message);
- }
- }
-
- private static string WriteDecay(BaseHouse house, DecayLevel from, DecayLevel to)
- {
- var sb = BridgeJson.Begin("house.decay")
- .Ser("serial", house.Serial)
- .Str("from", from.ToString())
- .Str("to", to.ToString())
- .Str("map", house.Map == null ? null : house.Map.Name)
- .Num("x", house.X).Num("y", house.Y).Num("z", house.Z);
-
- var region = house.Region;
- if (region != null)
- sb.Str("region", region.Name);
-
- var sign = house.Sign;
- if (sign != null)
- sb.Str("name", sign.GetName());
-
- var owner = house.Owner;
- if (owner != null)
- {
- sb.Ser("ownerSerial", owner.Serial);
- var acct = owner.Account as Account;
- if (acct != null)
- sb.Str("ownerAcct", acct.Username);
- }
-
- // Where a player would physically stand to see it.
- var ban = house.BanLocation;
- sb.Append(",\"ban\":{\"x\":").Append(ban.X)
- .Append(",\"y\":").Append(ban.Y)
- .Append(",\"z\":").Append(ban.Z).Append('}');
-
- sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o"));
- sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o"));
-
- return sb.End();
- }
-
- // ---- economy supply ----
-
- ///
- /// Money supply = the sum of every account's currency, as a periodic snapshot. This is
- /// the level; AccountGoldChange and the vendor events are the flow. The sidecar keeps
- /// both.
- ///
- private static void EconomySweep()
- {
- try
- {
- _economySweeps++;
-
- if (!BridgeLink.Connected)
- return;
-
- double totalCurrency = 0;
- int accounts = 0;
-
- foreach (Account a in Accounting.Accounts.GetAccounts())
- {
- totalCurrency += a.TotalCurrency;
- accounts++;
- }
-
- BridgeLink.Emit(BridgeJson.Begin("economy.supply")
- .Num("accounts", accounts)
- .Num("gold", (long)(totalCurrency * Account.CurrencyThreshold))
- .End());
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] economy sweep threw: {0}", ex.Message);
- }
- }
-
- /// Runs each sweep once, now. For `[bridge sweepnow`.
- public static void SweepOnce()
- {
- VitalsSweep();
- DecaySweep();
- EconomySweep();
- }
- }
-}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs b/overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs
deleted file mode 100644
index ddbd2a7..0000000
--- a/overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs
+++ /dev/null
@@ -1,158 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Mobiles;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Website-published news, pushed into the game's town criers.
- ///
- /// Inbound towncrier.add adds a global entry that every town crier announces until it
- /// expires; towncrier.remove pulls one early. Both run on the Core thread (inbound lines are
- /// marshaled through Timer.DelayCall before a handler sees them), which is required because
- /// AddEntry mutates a shared list and the criers send packets.
- ///
- /// Loopback is the trust boundary, but the caps here (line count/length, active-entry count,
- /// duration) are defense in depth: a compromised or buggy sidecar still cannot flood the
- /// criers or pin a message forever.
- ///
- public static class BridgeTownCrier
- {
- // Website id -> the entry we created for it, so a later remove can find it.
- private static readonly Dictionary _entries =
- new Dictionary(StringComparer.Ordinal);
-
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- BridgeBoot.RegisterHandler("towncrier.add", OnAdd);
- BridgeBoot.RegisterHandler("towncrier.remove", OnRemove);
- }
-
- private static void Reply(string kind, string id, string reason)
- {
- var sb = BridgeJson.Begin(kind);
- if (id != null) sb.Str("id", id);
- if (reason != null) sb.Str("reason", reason);
- BridgeLink.Emit(sb.End());
- }
-
- private static void OnAdd(Dictionary o)
- {
- var id = BridgeJson.GetString(o, "id");
-
- if (id == null)
- {
- Reply("towncrier.error", null, "missing id");
- return;
- }
-
- var lines = BridgeJson.GetStringList(o, "lines");
-
- if (lines.Count == 0)
- {
- Reply("towncrier.error", id, "no lines");
- return;
- }
-
- if (lines.Count > BridgeConfig.TownCrierMaxLines)
- {
- Reply("towncrier.error", id, "too many lines");
- return;
- }
-
- // Prune expired entries from our map before enforcing the active cap.
- PruneExpired();
-
- // Replacing an existing id is fine; otherwise enforce the active cap.
- if (!_entries.ContainsKey(id) && _entries.Count >= BridgeConfig.TownCrierMaxActive)
- {
- Reply("towncrier.error", id, "too many active entries");
- return;
- }
-
- var clean = new string[lines.Count];
- for (int i = 0; i < lines.Count; i++)
- {
- var line = lines[i] ?? "";
- if (line.Length > BridgeConfig.TownCrierMaxLineLength)
- line = line.Substring(0, BridgeConfig.TownCrierMaxLineLength);
- clean[i] = line;
- }
-
- int durationSec = BridgeJson.GetInt(o, "durationSec", 3600);
- if (durationSec < 1)
- durationSec = 1;
- if (durationSec > BridgeConfig.TownCrierMaxDurationSec)
- durationSec = BridgeConfig.TownCrierMaxDurationSec;
-
- try
- {
- // If this id already exists, replace it: remove the old entry first.
- TownCrierEntry old;
- if (_entries.TryGetValue(id, out old) && old != null)
- GlobalTownCrierEntryList.Instance.RemoveEntry(old);
-
- var entry = GlobalTownCrierEntryList.Instance.AddEntry(clean, TimeSpan.FromSeconds(durationSec));
- _entries[id] = entry;
-
- Reply("towncrier.ok", id, null);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] towncrier.add threw: {0}", ex.Message);
- Reply("towncrier.error", id, "internal error");
- }
- }
-
- private static void OnRemove(Dictionary o)
- {
- var id = BridgeJson.GetString(o, "id");
-
- if (id == null)
- {
- Reply("towncrier.error", null, "missing id");
- return;
- }
-
- TownCrierEntry entry;
- if (!_entries.TryGetValue(id, out entry))
- {
- Reply("towncrier.error", id, "unknown id");
- return;
- }
-
- _entries.Remove(id);
-
- try
- {
- if (entry != null)
- GlobalTownCrierEntryList.Instance.RemoveEntry(entry);
-
- Reply("towncrier.ok", id, null);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] towncrier.remove threw: {0}", ex.Message);
- Reply("towncrier.error", id, "internal error");
- }
- }
-
- private static void PruneExpired()
- {
- var doomed = new List();
-
- foreach (var kv in _entries)
- {
- if (kv.Value == null || kv.Value.Expired)
- doomed.Add(kv.Key);
- }
-
- foreach (var id in doomed)
- _entries.Remove(id);
- }
- }
-}
diff --git a/overlay/Scripts/Scripts.csproj b/overlay/Scripts/Scripts.csproj
deleted file mode 100644
index 105a3d9..0000000
--- a/overlay/Scripts/Scripts.csproj
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
- net48
- Library
- Scripts
- Server
- False
- False
- False
- True
- false
- x64
-
-
-
- ..\
- TRACE;DEBUG;NEWTIMERS;ServUO
- embedded
-
-
- ..\
- TRACE;NEWTIMERS;ServUO
- none
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/patches/BridgeModerationAudit.cs b/patches/BridgeModerationAudit.cs
deleted file mode 100644
index 9565816..0000000
--- a/patches/BridgeModerationAudit.cs
+++ /dev/null
@@ -1,133 +0,0 @@
-using System;
-
-using Server.Accounting;
-using Server.Commands;
-using Server.Mobiles;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// 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
- /// 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:
- /// - ban / kick: resolved with their target inside the stock generic command, which logs a
- /// line via CommandLogging.WriteLine. We tap the new CommandLogging.OnWrite event and
- /// parse the "... banning|kicking <target> ('acct')" line for action and target.
- /// - broadcast: [bcast carries its message as command args and hits no target, so
- /// EventSink.Command already sees it whole; we reshape it.
- ///
- /// Not in overlay/: it references CommandLogging.OnWrite, which exists only after
- /// patches/commandlogging-event.patch is applied. Shipping it in overlay/ would break the
- /// build on an unpatched install — the same reason BridgeVendorSale.cs lives in patches/.
- ///
- /// Runs on the Core thread (both sources raise synchronously in the command path). Every body
- /// is wrapped: a bridge exception must never escape into a staff command.
- ///
- public static class BridgeModerationAudit
- {
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- CommandLogging.OnWrite += OnCommandLog; // ban / kick (resolved, with target)
- EventSink.Command += OnStaffCommand; // broadcast (carries its message)
-
- Console.WriteLine("[Bridge] in-game moderation audit attached");
- }
-
- ///
- /// The stock ban/kick commands log "<level> <from> ('acct') banning|kicking
- /// <target> ('acct')" (Commands.cs KickCommand). Match the verb, take the target's
- /// account from the trailing "('acct')", and forward. Non-moderation lines are ignored.
- ///
- private static void OnCommandLog(Mobile from, string text)
- {
- try
- {
- if (from == null || text == null)
- return;
-
- string action;
- int at;
-
- if ((at = text.IndexOf(" banning ", StringComparison.Ordinal)) >= 0)
- action = "ban";
- else if ((at = text.IndexOf(" kicking ", StringComparison.Ordinal)) >= 0)
- action = "kick";
- else
- return;
-
- var tail = text.Substring(at + 9); // past " banning " / " kicking "
- Emit(action, from, ExtractAccount(tail), text);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] mod-audit log parse threw: {0}", ex.Message);
- }
- }
-
- /// [bcast / [bc / [b — a staff broadcast. Its message is the command args.
- private static void OnStaffCommand(CommandEventArgs e)
- {
- try
- {
- if (e == null || e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
- return;
-
- var cmd = e.Command;
- if (cmd == null)
- return;
-
- cmd = cmd.ToLowerInvariant();
- if (cmd != "bcast" && cmd != "bc" && cmd != "b")
- return;
-
- Emit("broadcast", e.Mobile, null, e.ArgString);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] mod-audit command threw: {0}", ex.Message);
- }
- }
-
- /// Pulls the account from a CommandLogging.Format rendering's trailing "('account')".
- private static string ExtractAccount(string formatted)
- {
- if (formatted == null)
- return null;
-
- int open = formatted.LastIndexOf("('", StringComparison.Ordinal);
- if (open < 0)
- return null;
-
- int close = formatted.IndexOf("')", open, StringComparison.Ordinal);
- if (close < 0)
- return null;
-
- return formatted.Substring(open + 2, close - (open + 2));
- }
-
- ///
- /// Emits admin.audit with origin=in-game. The actor is the staff member's account name
- /// (no "web:" prefix — that, plus the origin field, is how the website tells the two
- /// sources apart). `detail` carries the raw context so nothing is lost if a target could
- /// not be parsed.
- ///
- private static void Emit(string action, Mobile actor, string target, string detail)
- {
- var acct = actor.Account as Account;
- var actorName = acct != null ? acct.Username : actor.Name;
-
- BridgeLink.Emit(BridgeJson.Begin("admin.audit")
- .Str("origin", "in-game")
- .Str("action", action)
- .Str("actor", actorName)
- .Str("target", target)
- .Str("detail", detail)
- .End());
- }
- }
-}
diff --git a/patches/BridgeVendorSale.cs b/patches/BridgeVendorSale.cs
deleted file mode 100644
index 459102c..0000000
--- a/patches/BridgeVendorSale.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-using System;
-
-using Server;
-using Server.Accounting;
-using Server.Custom.Bridge;
-
-namespace Server.Custom.Bridge
-{
- ///
- /// Subscribes to the PlayerVendorSale event added by the Phase 7 core patches. This file is
- /// part of that coupled unit and is NOT in overlay/, because it references
- /// PlayerVendorSaleEventArgs, which does not exist until the EventSink patch is applied —
- /// shipping it in overlay/ would break the build on any install without the patch.
- ///
- /// Deploy: apply patches/playervendor-sale-*.patch, then copy this file to
- /// Scripts/Custom/Bridge/BridgeVendorSale.cs.
- ///
- /// The event fires at the committed sale (PlayerVendorBuyGump.OnResponse), on the Core
- /// thread, with buyer, vendor owner, item, price, and commission all in scope — richer than
- /// the NPC ValidVendor* events (which lack owner and commission) and, unlike them, on a
- /// committed sale rather than a validation stage. It is the backbone of the cheat-detection
- /// feed: same-account buyer≈owner is gold laundering, off-market prices and burst patterns
- /// are visible to the sidecar.
- ///
- public static class BridgeVendorSale
- {
- public static void Initialize()
- {
- if (!BridgeConfig.Enabled)
- return;
-
- EventSink.PlayerVendorSale += OnPlayerVendorSale;
- Console.WriteLine("[Bridge] player-vendor sale stream attached");
- }
-
- private static void OnPlayerVendorSale(PlayerVendorSaleEventArgs e)
- {
- try
- {
- var sb = BridgeJson.Begin("vendor.sale")
- .Bool("committed", true);
-
- // Buyer
- if (e.Buyer != null)
- {
- sb.Ser("buyerSerial", e.Buyer.Serial);
- var ba = e.Buyer.Account as Account;
- if (ba != null)
- sb.Str("buyerAcct", ba.Username);
- }
-
- // Vendor owner — the player who actually profits.
- if (e.Owner != null)
- {
- sb.Ser("ownerSerial", e.Owner.Serial);
- var oa = e.Owner.Account as Account;
- if (oa != null)
- sb.Str("ownerAcct", oa.Username);
- }
-
- if (e.Vendor != null)
- sb.Ser("vendorSerial", e.Vendor.Serial);
-
- if (e.Item != null)
- {
- sb.Ser("itemSerial", e.Item.Serial);
- sb.Str("itemType", e.Item.GetType().Name);
- sb.Num("itemId", e.Item.ItemID);
- sb.Num("amount", e.Item.Amount);
- }
-
- sb.Num("price", e.Price);
- sb.Num("commission", e.Commission);
-
- BridgeLink.Emit(sb.End());
- }
- catch (Exception ex)
- {
- Console.WriteLine("[Bridge] vendor.sale handler threw: {0}", ex.Message);
- }
- }
- }
-}
diff --git a/patches/README.md b/patches/README.md
deleted file mode 100644
index 070d6bc..0000000
--- a/patches/README.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# patches
-
-Unified diffs against stock ServUO 57.4 for files the bridge must **modify** rather than add. Anything that can be shipped as a whole file belongs in `overlay/` instead.
-
-Apply from the server root:
-
-```bash
-git apply --check patches/.patch # dry run
-git apply patches/.patch
-```
-
-## 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 [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:
-
-| Item | Target | What |
-|------|--------|------|
-| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | Adds the `PlayerVendorSale` delegate, `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }`, the event field, and `InvokePlayerVendorSale`. |
-| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | One `InvokePlayerVendorSale(...)` call right after the committed `HoldGold +=`. |
-| `BridgeVendorSale.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber that emits `vendor.sale`. **Not** in `overlay/` because it references `PlayerVendorSaleEventArgs`, which does not exist until the EventSink patch is applied — shipping it in overlay would break the build on any unpatched install. |
-
-```bash
-cd
-git apply --check patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch # dry run
-git apply patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
-cp patches/BridgeVendorSale.cs Scripts/Custom/Bridge/BridgeVendorSale.cs
-```
-
-Both patches are `git`-format and verified with `git apply --check` against stock ServUO 57.4. Modifying `EventSink.cs` means the **core** rebuilds, so `ScriptCompiler`'s dynamic script build is not enough — rebuild the solution (`dotnet build ServUO.sln`) or the server binary.
-
-Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
-
-## In-game moderation audit (admin controls §5.5)
-
-So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
-
-| Item | Target | What |
-|------|--------|------|
-| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
-| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
-
-```bash
-cd
-git apply --check patches/commandlogging-event.patch # dry run
-git apply patches/commandlogging-event.patch
-cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
-```
-
-`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
-
-## Note on `Scripts.csproj`
-
-Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
-
-## Note on shard repairs
-
-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.
diff --git a/patches/commandlogging-event.patch b/patches/commandlogging-event.patch
deleted file mode 100644
index 37d5f3f..0000000
--- a/patches/commandlogging-event.patch
+++ /dev/null
@@ -1,33 +0,0 @@
---- a/Scripts/Commands/Logging.cs
-+++ b/Scripts/Commands/Logging.cs
-@@ -75,16 +75,27 @@
- return o;
- }
-
-+ ///
-+ /// Raised for every staff command log line — even when file logging is disabled — so an
-+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
-+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
-+ ///
-+ public static event Action OnWrite;
-+
- public static void WriteLine(Mobile from, string format, params object[] args)
- {
-- if (!m_Enabled)
-- return;
--
- WriteLine(from, String.Format(format, args));
- }
-
- public static void WriteLine(Mobile from, string text)
- {
-+ var onWrite = OnWrite;
-+ if (onWrite != null)
-+ {
-+ try { onWrite(from, text); }
-+ catch { }
-+ }
-+
- if (!m_Enabled)
- return;
-
diff --git a/patches/playervendor-sale-eventsink.patch b/patches/playervendor-sale-eventsink.patch
deleted file mode 100644
index d8714c7..0000000
--- a/patches/playervendor-sale-eventsink.patch
+++ /dev/null
@@ -1,66 +0,0 @@
-diff --git a/Server/EventSink.cs b/Server/EventSink.cs
-index d30788f..1da2667 100644
---- a/Server/EventSink.cs
-+++ b/Server/EventSink.cs
-@@ -171,6 +171,8 @@ namespace Server
-
- public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e);
-
-+ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);
-+
- public delegate void CorpseLootEventHandler(CorpseLootEventArgs e);
-
- public delegate void RepairItemEventHandler(RepairItemEventArgs e);
-@@ -1521,6 +1523,29 @@ namespace Server
- }
- }
-
-+ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in
-+ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission
-+ // are all in scope -- the data the bridge's cheat-detection feed needs.
-+ public class PlayerVendorSaleEventArgs : EventArgs
-+ {
-+ public Mobile Buyer { get; set; }
-+ public Mobile Vendor { get; set; }
-+ public Mobile Owner { get; set; }
-+ public Item Item { get; set; }
-+ public int Price { get; set; }
-+ public int Commission { get; set; }
-+
-+ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission)
-+ {
-+ Buyer = buyer;
-+ Vendor = vendor;
-+ Owner = owner;
-+ Item = item;
-+ Price = price;
-+ Commission = commission;
-+ }
-+ }
-+
- public class CorpseLootEventArgs : EventArgs
- {
- public Mobile Mobile { get; set; }
-@@ -1771,6 +1796,7 @@ namespace Server
- public static event TameCreatureEventHandler TameCreature;
- public static event ValidVendorPurchaseEventHandler ValidVendorPurchase;
- public static event ValidVendorSellEventHandler ValidVendorSell;
-+ public static event PlayerVendorSaleEventHandler PlayerVendorSale;
- public static event CorpseLootEventHandler CorpseLoot;
- public static event RepairItemEventHandler RepairItem;
- public static event AlterItemEventHandler AlterItem;
-@@ -2416,6 +2442,14 @@ namespace Server
- }
- }
-
-+ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)
-+ {
-+ if (PlayerVendorSale != null)
-+ {
-+ PlayerVendorSale(e);
-+ }
-+ }
-+
- public static void InvokeCorpseLoot(CorpseLootEventArgs e)
- {
- if (CorpseLoot != null)
diff --git a/patches/playervendor-sale-gump.patch b/patches/playervendor-sale-gump.patch
deleted file mode 100644
index 1eedccd..0000000
--- a/patches/playervendor-sale-gump.patch
+++ /dev/null
@@ -1,15 +0,0 @@
-diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs
-index 049aae6..f1b30d2 100644
---- a/Scripts/Gumps/PlayerVendorGumps.cs
-+++ b/Scripts/Gumps/PlayerVendorGumps.cs
-@@ -95,6 +95,10 @@ namespace Server.Gumps
-
- m_Vendor.HoldGold += m_VI.Price - commission;
-
-+ // uo-link: the only committed-sale hook for player vendors (no EventSink exists).
-+ EventSink.InvokePlayerVendorSale(
-+ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
-+
- from.SendLocalizedMessage(503201); // You take the item.
- }
- }
diff --git a/tools/scaffolding/BridgeAuditProbe.cs b/tools/scaffolding/BridgeAuditProbe.cs
deleted file mode 100644
index de22467..0000000
--- a/tools/scaffolding/BridgeAuditProbe.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using System;
-
-using Server.Accounting;
-using Server.Commands;
-using Server.Mobiles;
-
-namespace Server.Custom
-{
- ///
- /// Exercises the in-game moderation-audit forwarding (BridgeModerationAudit) without a game
- /// client, so the CommandLogging.OnWrite patch and the admin.audit normalizer can be verified
- /// end-to-end from a stub sidecar.
- ///
- /// - Broadcast is a *genuine* trigger: CommandSystem.Handle runs [bcast, which raises
- /// EventSink.Command exactly as a staff keystroke would.
- /// - Ban/kick can't complete headlessly (they arm a target cursor with no client to click),
- /// so we call CommandLogging.WriteLine with the stock KickCommand line format — the same
- /// call that command makes at Commands.cs:1211, which is the point we tap.
- /// - A non-moderation log line confirms the normalizer ignores everything else.
- ///
- /// Test scaffolding. Never deployed. Gated behind Bridge.AuditProbeOnStart (absent in a
- /// shipped Bridge.cfg, so Config.Get returns false and it never runs in production).
- ///
- public static class BridgeAuditProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.AuditProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- var staffAcct = Accounting.Accounts.GetAccount("whitlocktech") as Account;
- var targetAcct = Accounting.Accounts.GetAccount("seed_010") as Account;
-
- var from = staffAcct == null ? null : staffAcct[0];
- var target = targetAcct == null ? null : targetAcct[0];
-
- if (from == null || target == null)
- {
- Console.WriteLine("[AuditProbe] need whitlocktech + seed_010 chars; seed the world first");
- return;
- }
-
- Console.WriteLine("[AuditProbe] genuine broadcast via [bcast ...");
- CommandSystem.Handle(from, CommandSystem.Prefix + "bcast in-game audit probe");
-
- Console.WriteLine("[AuditProbe] simulating a resolved ban log line ...");
- CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
- from.AccessLevel, CommandLogging.Format(from), "banning", CommandLogging.Format(target));
-
- Console.WriteLine("[AuditProbe] simulating a resolved kick log line ...");
- CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
- from.AccessLevel, CommandLogging.Format(from), "kicking", CommandLogging.Format(target));
-
- Console.WriteLine("[AuditProbe] a non-moderation line (should be ignored) ...");
- CommandLogging.WriteLine(from, "{0} {1} used command '{2}'",
- from.AccessLevel, CommandLogging.Format(from), "Go 1 1 0");
-
- Console.WriteLine("[AuditProbe] done");
- }
- catch (Exception ex)
- {
- Console.WriteLine("[AuditProbe] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgeCrierProbe.cs b/tools/scaffolding/BridgeCrierProbe.cs
deleted file mode 100644
index 5c1d66e..0000000
--- a/tools/scaffolding/BridgeCrierProbe.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System;
-using System.Text;
-
-using Server.Mobiles;
-
-namespace Server.Custom
-{
- ///
- /// Logs the global town-crier entry list every few seconds so a towncrier.add / remove
- /// round-trip can be observed landing in the actual game state, not just acknowledged.
- ///
- /// Test scaffolding. Never deployed. Read-only.
- ///
- public static class BridgeCrierProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.CrierProbeOnStart", false))
- EventSink.ServerStarted += () =>
- Timer.DelayCall(TimeSpan.FromSeconds(3.0), TimeSpan.FromSeconds(3.0), Dump);
- }
-
- private static int _tick;
-
- private static void Dump()
- {
- try
- {
- var list = GlobalTownCrierEntryList.Instance;
- var entries = list == null ? null : list.Entries;
- int count = entries == null ? 0 : entries.Count;
-
- var sb = new StringBuilder();
- sb.AppendFormat("[CrierProbe] tick {0}: {1} entries", ++_tick, count);
-
- if (entries != null)
- {
- for (int i = 0; i < entries.Count; i++)
- {
- var e = entries[i];
- if (e == null || e.Lines == null)
- continue;
- sb.AppendFormat(" | [{0}]", String.Join(" / ", e.Lines));
- }
- }
-
- Console.WriteLine(sb.ToString());
-
- if (_tick >= 6)
- Timer.DelayCall(TimeSpan.Zero, () => { }); // no-op; probe stops being interesting
- }
- catch (Exception ex)
- {
- Console.WriteLine("[CrierProbe] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgeEventProbe.cs b/tools/scaffolding/BridgeEventProbe.cs
deleted file mode 100644
index cf38859..0000000
--- a/tools/scaffolding/BridgeEventProbe.cs
+++ /dev/null
@@ -1,68 +0,0 @@
-using System;
-
-using Server.Accounting;
-using Server.Mobiles;
-
-namespace Server.Custom
-{
- ///
- /// Fires a handful of the bridge's event streams by doing real things to the world, so the
- /// emit path and JSON shape can be verified without a game client attached.
- ///
- /// These are genuine triggers, not synthetic EventSink.Invoke calls: DepositGold raises
- /// AccountGoldChange from Account.cs:1635, the Fame/Karma setters raise theirs from
- /// Mobile.cs:7121,7141, and World.Save raises the save boundaries from World.cs:1151,1202.
- /// Calling Invoke directly would prove only that the handler compiles.
- ///
- /// Test scaffolding. Never deployed. Mutates the world (gold, fame, karma) and saves.
- /// Run only against a seeded throwaway world with a backup.
- ///
- public static class BridgeEventProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.EventProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- var acct = Accounting.Accounts.GetAccount("seed_000") as Account;
-
- if (acct == null)
- {
- Console.WriteLine("[EventProbe] no seed_000 account; seed the world first");
- return;
- }
-
- var pm = acct[0] as PlayerMobile;
-
- if (pm == null)
- {
- Console.WriteLine("[EventProbe] seed_000 has no character in slot 0");
- return;
- }
-
- Console.WriteLine("[EventProbe] firing gold.change ...");
- acct.DepositGold(12345);
-
- Console.WriteLine("[EventProbe] firing fame.change ...");
- pm.Fame = pm.Fame + 100;
-
- Console.WriteLine("[EventProbe] firing karma.change ...");
- pm.Karma = pm.Karma - 50;
-
- Console.WriteLine("[EventProbe] firing world.save.before / world.save.after ...");
- World.Save();
-
- Console.WriteLine("[EventProbe] done");
- }
- catch (Exception ex)
- {
- Console.WriteLine("[EventProbe] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgeLinkProbe.cs b/tools/scaffolding/BridgeLinkProbe.cs
deleted file mode 100644
index 7004b80..0000000
--- a/tools/scaffolding/BridgeLinkProbe.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using System;
-
-using Server.Accounting;
-using Server.Custom.Bridge;
-using Server.Mobiles;
-
-namespace Server.Custom
-{
- ///
- /// Triggers the [link flow for a seeded character without a game client, so the account
- /// linking round-trip can be tested end to end: RequestLink emits link.request, the test
- /// sidecar reads the code and sends link.confirm, and the account gets tagged.
- ///
- /// Test scaffolding. Never deployed. Writes an account tag (persisted on save).
- ///
- public static class BridgeLinkProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.LinkProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- // Use a seed account not already linked. seed_001 slot 0.
- var acct = Accounting.Accounts.GetAccount("seed_001") as Account;
- var pm = acct == null ? null : acct[0] as PlayerMobile;
-
- if (pm == null)
- {
- Console.WriteLine("[LinkProbe] seed_001 slot 0 not found; seed the world first");
- return;
- }
-
- var existing = acct.GetTag("WebsiteUserId");
- if (existing != null)
- Console.WriteLine("[LinkProbe] seed_001 already linked to {0}; re-running anyway", existing);
-
- Console.WriteLine("[LinkProbe] requesting link for seed_001 / {0}", pm.Name);
- BridgeAccountLink.RequestLink(pm);
- Console.WriteLine("[LinkProbe] link.request emitted; watch for the code -> link.confirm -> link.ok");
-
- // Give the confirm time to land and set the tag, then save so it reaches
- // accounts.xml. This proves persistence across a restart.
- Timer.DelayCall(TimeSpan.FromSeconds(5.0), () =>
- {
- var tag = acct.GetTag("WebsiteUserId");
- Console.WriteLine("[LinkProbe] after confirm, seed_001 WebsiteUserId tag = {0}",
- tag ?? "(null)");
- Console.WriteLine("[LinkProbe] saving world to persist the tag...");
- World.Save();
- Console.WriteLine("[LinkProbe] saved");
- });
- }
- catch (Exception ex)
- {
- Console.WriteLine("[LinkProbe] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgePageProbe.cs b/tools/scaffolding/BridgePageProbe.cs
deleted file mode 100644
index 3e518d5..0000000
--- a/tools/scaffolding/BridgePageProbe.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System;
-
-using Server.Accounting;
-using Server.Engines.Help;
-
-namespace Server.Custom
-{
- ///
- /// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages
- /// (poll/stream + snapshot + respond/close) can be verified without a game client.
- ///
- /// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client:
- /// each entry's InternalTimer would remove the page on its first tick because the sender has
- /// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop
- /// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot,
- /// respond, close — then operates on real queue entries.
- ///
- /// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart.
- ///
- public static class BridgePageProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.PageProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck);
- Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug);
- Console.WriteLine("[PageProbe] done");
- }
- catch (Exception ex)
- {
- Console.WriteLine("[PageProbe] FAILED: " + ex);
- }
- }
-
- private static void Enqueue(string account, string message, PageType type)
- {
- var acct = Accounting.Accounts.GetAccount(account) as Account;
- var sender = acct == null ? null : acct[0];
-
- if (sender == null)
- {
- Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account);
- return;
- }
-
- var entry = new PageEntry(sender, message, type);
- PageQueue.Enqueue(entry);
- entry.Stop(); // keep it in the queue despite the offline sender
-
- Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})",
- type, account, sender.Serial.Value);
- }
- }
-}
diff --git a/tools/scaffolding/BridgeProbe.cs b/tools/scaffolding/BridgeProbe.cs
deleted file mode 100644
index a1d4b45..0000000
--- a/tools/scaffolding/BridgeProbe.cs
+++ /dev/null
@@ -1,405 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Text;
-
-using Server.Accounting;
-using Server.Items;
-using Server.Mobiles;
-using Server.Multis;
-
-namespace Server.Custom
-{
- ///
- /// Measures the main-thread cost of every read the bridge plugin would perform, against
- /// whatever world is currently loaded. Read-only. Test scaffolding, not part of the bridge.
- ///
- /// Everything here runs on the Core thread, which is exactly where the real plugin's
- /// reads must run, so these timings are the ones that matter for frame budget.
- ///
- public static class BridgeProbe
- {
- private const int Iterations = 20;
-
- private static readonly AosAttribute[] AllAttrs =
- (AosAttribute[])Enum.GetValues(typeof(AosAttribute));
-
- private static readonly AosWeaponAttribute[] AllWeaponAttrs =
- (AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
-
- private static readonly AosArmorAttribute[] AllArmorAttrs =
- (AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
-
- public static void Initialize()
- {
- if (Config.Get("Bridge.ProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(2.0), Run);
- }
-
- private static void Log(string fmt, params object[] args)
- {
- Console.WriteLine("[BridgeProbe] " + String.Format(fmt, args));
- }
-
- private static void Run()
- {
- try
- {
- var players = CollectSeededChars();
- var houses = BaseHouse.AllHouses;
- var vendors = PlayerVendor.PlayerVendors ?? new List();
-
- Log("world: {0} seeded chars, {1} houses, {2} vendors, {3} accounts",
- players.Count, houses.Count, vendors.Count, Accounting.Accounts.Count);
- Log("thread: {0} (id {1})",
- System.Threading.Thread.CurrentThread.Name,
- System.Threading.Thread.CurrentThread.ManagedThreadId);
- Log("");
-
- // ---- one full character profile, the heaviest single read ----
- var sb = new StringBuilder(8192);
- double perProfile = Time(() =>
- {
- for (int i = 0; i < players.Count; i++)
- {
- sb.Clear();
- WriteProfile(sb, players[i]);
- }
- }) / Math.Max(1, players.Count);
-
- sb.Clear();
- if (players.Count > 0)
- WriteProfile(sb, players[0]);
-
- int profileBytes = sb.Length;
-
- Log("char.profile {0,8:F3} ms/char {1,6} bytes json -> {2:F1} ms for all {3}",
- perProfile, profileBytes, perProfile * players.Count, players.Count);
-
- // ---- vitals: the 30s sweep the doc proposes ----
- double vitals = Time(() =>
- {
- var b = new StringBuilder(256);
- for (int i = 0; i < players.Count; i++)
- {
- b.Clear();
- WriteVitals(b, players[i]);
- }
- });
-
- Log("vitals sweep {0,8:F3} ms for {1} chars ({2:F4} ms/char)",
- vitals, players.Count, vitals / Math.Max(1, players.Count));
-
- // ---- house decay sweep (III.3) ----
- double decay = Time(() =>
- {
- for (int i = 0; i < houses.Count; i++)
- {
- var lvl = houses[i].DecayLevel;
- GC.KeepAlive(lvl);
- }
- });
-
- Log("decay sweep {0,8:F3} ms for {1} houses ({2:F4} ms/house)",
- decay, houses.Count, decay / Math.Max(1, houses.Count));
-
- // ---- economy: money supply snapshot ----
- double econ = 0;
- double total = 0;
- econ = Time(() =>
- {
- total = 0;
- foreach (Account a in Accounting.Accounts.GetAccounts())
- total += a.TotalCurrency;
- });
-
- Log("economy sweep {0,8:F3} ms for {1} accounts (supply {2:N0} gold)",
- econ, Accounting.Accounts.Count, total * Account.CurrencyThreshold);
-
- // ---- player vendor snapshot ----
- int listings = 0;
- double vend = Time(() =>
- {
- listings = 0;
- var b = new StringBuilder(4096);
- for (int i = 0; i < vendors.Count; i++)
- {
- b.Clear();
- listings += WriteVendor(b, vendors[i]);
- }
- });
-
- Log("vendor snap {0,8:F3} ms for {1} vendors ({2} listings)",
- vend, vendors.Count, listings);
-
- Log("");
- Log("--- extrapolation (linear, same gear complexity) ---");
- Log(" vitals sweep @ 200 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 200);
- Log(" vitals sweep @ 1000 online: {0,7:F2} ms", vitals / Math.Max(1, players.Count) * 1000);
- Log(" profiles for 1000 chars : {0,7:F1} ms <-- never do this in a sweep",
- perProfile * 1000);
- Log(" decay sweep @ 2000 houses: {0,7:F2} ms", decay / Math.Max(1, houses.Count) * 2000);
- Log(" economy @ 5000 accts : {0,7:F2} ms", econ / Math.Max(1, Accounting.Accounts.Count) * 5000);
- }
- catch (Exception ex)
- {
- Log("FAILED: " + ex);
- }
- }
-
- /// Best-of-N: the minimum is the least noisy estimate of true cost.
- private static double Time(Action action)
- {
- action(); // warm up JIT and caches
-
- double best = double.MaxValue;
- var sw = new Stopwatch();
-
- for (int i = 0; i < Iterations; i++)
- {
- sw.Restart();
- action();
- sw.Stop();
-
- double ms = sw.Elapsed.TotalMilliseconds;
-
- if (ms < best)
- best = ms;
- }
-
- return best;
- }
-
- private static List CollectSeededChars()
- {
- var list = new List();
-
- foreach (Account a in Accounting.Accounts.GetAccounts())
- {
- if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
- continue;
-
- for (int i = 0; i < a.Length; i++)
- {
- var pm = a[i] as PlayerMobile;
-
- if (pm != null)
- list.Add(pm);
- }
- }
-
- return list;
- }
-
- private static void WriteVitals(StringBuilder sb, PlayerMobile m)
- {
- sb.Append("{\"kind\":\"char.vitals\",\"serial\":\"0x");
- sb.Append(m.Serial.Value.ToString("X"));
- sb.Append("\",\"hits\":").Append(m.Hits);
- sb.Append(",\"hitsMax\":").Append(m.HitsMax);
- sb.Append(",\"mana\":").Append(m.Mana);
- sb.Append(",\"stam\":").Append(m.Stam);
- sb.Append(",\"str\":").Append(m.Str);
- sb.Append(",\"dex\":").Append(m.Dex);
- sb.Append(",\"int\":").Append(m.Int);
- sb.Append(",\"x\":").Append(m.X);
- sb.Append(",\"y\":").Append(m.Y);
- sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
- sb.Append('}');
- }
-
- private static void WriteProfile(StringBuilder sb, PlayerMobile m)
- {
- sb.Append("{\"kind\":\"char.profile\",\"serial\":\"0x");
- sb.Append(m.Serial.Value.ToString("X"));
- sb.Append("\",\"name\":\"").Append(m.Name).Append('"');
- sb.Append(",\"body\":").Append(m.Body.BodyID);
- sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
-
- sb.Append(",\"stats\":{\"str\":").Append(m.Str);
- sb.Append(",\"dex\":").Append(m.Dex);
- sb.Append(",\"int\":").Append(m.Int);
- sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
- sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
- sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
- sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
- sb.Append(",\"luck\":").Append(m.Luck);
- sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
- sb.Append(",\"fire\":").Append(m.FireResistance);
- sb.Append(",\"cold\":").Append(m.ColdResistance);
- sb.Append(",\"pois\":").Append(m.PoisonResistance);
- sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
-
- sb.Append(",\"skills\":[");
- bool first = true;
- for (int i = 0; i < m.Skills.Length; i++)
- {
- var s = m.Skills[i];
-
- if (s.Base <= 0.0)
- continue; // untrained: the bridge should not ship ~50 zeroes per char
-
- if (!first)
- sb.Append(',');
- first = false;
-
- sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
- sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
- sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
- sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
- sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
- }
- sb.Append(']');
-
- sb.Append(",\"equipment\":[");
- first = true;
- foreach (var item in m.Items)
- {
- if (item.Layer == Layer.Backpack || item.Layer == Layer.Bank ||
- item.Layer == Layer.Hair || item.Layer == Layer.FacialHair ||
- item.Layer == Layer.Mount)
- continue;
-
- if (!first)
- sb.Append(',');
- first = false;
-
- sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
- sb.Append("\",\"layer\":\"").Append(item.Layer).Append('"');
- sb.Append(",\"itemId\":").Append(item.ItemID);
- sb.Append(",\"hue\":").Append(item.Hue);
- sb.Append(",\"cliloc\":").Append(item.LabelNumber);
-
- if (item.Name != null)
- sb.Append(",\"name\":\"").Append(item.Name).Append('"');
-
- sb.Append(",\"mods\":{");
- bool m1 = true;
-
- var weapon = item as BaseWeapon;
- var armor = item as BaseArmor;
-
- if (weapon != null)
- {
- sb.Append("\"minDamage\":").Append(weapon.MinDamage);
- sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage);
- m1 = false;
-
- WriteAttrs(sb, weapon.Attributes, ref m1);
- WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref m1);
- }
- else if (armor != null)
- {
- sb.Append("\"baseRating\":").Append(armor.BaseArmorRating);
- m1 = false;
-
- WriteAttrs(sb, armor.Attributes, ref m1);
- WriteArmorAttrs(sb, armor.ArmorAttributes, ref m1);
- }
-
- sb.Append("}}");
- }
- sb.Append(']');
- sb.Append('}');
- }
-
- private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
- {
- if (a == null)
- return;
-
- for (int i = 0; i < AllAttrs.Length; i++)
- {
- int v = a[AllAttrs[i]];
-
- if (v == 0)
- continue;
-
- if (!first)
- sb.Append(',');
- first = false;
-
- sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
- }
- }
-
- private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
- {
- if (a == null)
- return;
-
- for (int i = 0; i < AllWeaponAttrs.Length; i++)
- {
- int v = a[AllWeaponAttrs[i]];
-
- if (v == 0)
- continue;
-
- if (!first)
- sb.Append(',');
- first = false;
-
- sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
- }
- }
-
- private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
- {
- if (a == null)
- return;
-
- for (int i = 0; i < AllArmorAttrs.Length; i++)
- {
- int v = a[AllArmorAttrs[i]];
-
- if (v == 0)
- continue;
-
- if (!first)
- sb.Append(',');
- first = false;
-
- sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
- }
- }
-
- private static int WriteVendor(StringBuilder sb, PlayerVendor v)
- {
- int count = 0;
-
- sb.Append("{\"kind\":\"vendor.snapshot\",\"serial\":\"0x");
- sb.Append(v.Serial.Value.ToString("X"));
- sb.Append("\",\"holdGold\":").Append(v.HoldGold);
- sb.Append(",\"listings\":[");
-
- var pack = v.Backpack;
-
- if (pack != null)
- {
- bool first = true;
-
- foreach (var item in pack.Items)
- {
- var vi = v.GetVendorItem(item);
-
- if (vi == null)
- continue;
-
- if (!first)
- sb.Append(',');
- first = false;
- count++;
-
- sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X"));
- sb.Append("\",\"itemId\":").Append(item.ItemID);
- sb.Append(",\"price\":").Append(vi.Price);
- sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false");
- sb.Append('}');
- }
- }
-
- sb.Append("]}");
- return count;
- }
- }
-}
diff --git a/tools/scaffolding/BridgeSeeder.cs b/tools/scaffolding/BridgeSeeder.cs
deleted file mode 100644
index 7e8b908..0000000
--- a/tools/scaffolding/BridgeSeeder.cs
+++ /dev/null
@@ -1,455 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Reflection;
-
-using Server.Accounting;
-using Server.Commands;
-using Server.Items;
-using Server.Mobiles;
-using Server.Multis;
-
-namespace Server.Custom
-{
- ///
- /// Populates a test shard with synthetic accounts, characters, houses and player
- /// vendors so the ServUO/sidecar bridge can be exercised at a realistic scale.
- ///
- /// Test scaffolding. Not part of the bridge. Remove before production use.
- ///
- /// A house only reaches IDOC when BaseHouse.CanDecay is true, and CanDecay is true
- /// only for DecayType.Condemned or DecayType.ManualRefresh. An active owner's newest
- /// house is AutoRefresh, which never decays. So the decaying houses below are given
- /// to accounts whose LastLogin is backdated past Account.InactiveDuration (180 days),
- /// which makes them Condemned.
- ///
- /// Decay stage is then forced with SetDynamicDecay rather than by backdating
- /// LastRefreshed: this shard is EJ, so Core.ML is true, so DynamicDecay.Enabled is
- /// true and BaseHouse.GetOldDecayLevel (the percentage-of-DecayPeriod model) is never
- /// reached.
- ///
- public static class BridgeSeeder
- {
- private const string Prefix = "seed_";
-
- private const int Accounts = 50;
- private const int CharsPerAccount = 3;
-
- private const int ActiveHouses = 12; // healthy, owned by active accounts
- private const int DecayingHouses = 18; // owned by inactive accounts, staged below
- private const int VendorHouses = 15;
- private const int VendorsPerHouse = 2;
- private const int ItemsPerVendor = 40;
-
- private static readonly Point3D HouseOrigin = new Point3D(1400, 1600, 0);
- private const int HouseSpacing = 40;
- private const int HousesPerRow = 6;
-
- // Spread the decaying houses across stages so a transition sweep sees variety.
- private static readonly DecayLevel[] DecayStages =
- {
- DecayLevel.Slightly, DecayLevel.Somewhat, DecayLevel.Fairly,
- DecayLevel.Greatly, DecayLevel.IDOC, DecayLevel.IDOC
- };
-
- private static readonly Random Rng = new Random(20260710);
-
- // VendorItem.Price is get-only and PlayerVendor.SetVendorItem is private, so a seeded
- // vendor would otherwise be stuck at the 999 default that OnSubItemAdded assigns.
- private static readonly MethodInfo SetVendorItemMethod = typeof(PlayerVendor).GetMethod(
- "SetVendorItem",
- BindingFlags.Instance | BindingFlags.NonPublic,
- null,
- new[] { typeof(Item), typeof(int), typeof(string) },
- null);
-
- public static void Initialize()
- {
- CommandSystem.Register("seedworld", AccessLevel.Administrator, Seed_OnCommand);
- CommandSystem.Register("unseedworld", AccessLevel.Administrator, Unseed_OnCommand);
-
- if (Config.Get("Bridge.SeedOnStart", false))
- EventSink.ServerStarted += () => Run(null, save: true);
-
- if (Config.Get("Bridge.CensusOnStart", false))
- EventSink.ServerStarted += Census;
- }
-
- ///
- /// Reports what the seeded world actually contains after a load, rather than what
- /// the seeder intended to create. Read-only.
- ///
- private static void Census()
- {
- try
- {
- var byLevel = new Dictionary();
-
- foreach (var house in BaseHouse.AllHouses)
- {
- var level = house.DecayLevel;
- int n;
- byLevel.TryGetValue(level, out n);
- byLevel[level] = n + 1;
- }
-
- Console.WriteLine("[BridgeSeeder] houses={0}", BaseHouse.AllHouses.Count);
-
- foreach (var kv in byLevel)
- Console.WriteLine("[BridgeSeeder] decay {0,-18} {1}", kv.Key, kv.Value);
-
- int chars = 0, totalEquipped = 0, naked = 0;
-
- foreach (Account a in Accounting.Accounts.GetAccounts())
- {
- if (!a.Username.StartsWith(Prefix, StringComparison.Ordinal))
- continue;
-
- for (int i = 0; i < a.Length; i++)
- {
- var m = a[i];
-
- if (m == null)
- continue;
-
- chars++;
-
- int worn = 0;
-
- foreach (var item in m.Items)
- {
- if (item.Layer != Layer.Backpack && item.Layer != Layer.Bank &&
- item.Layer != Layer.Hair && item.Layer != Layer.FacialHair)
- worn++;
- }
-
- totalEquipped += worn;
-
- if (worn == 0)
- naked++;
- }
- }
-
- Console.WriteLine("[BridgeSeeder] seeded chars={0} avgEquipped={1:F2} naked={2}",
- chars, chars == 0 ? 0.0 : (double)totalEquipped / chars, naked);
-
- Console.WriteLine("[BridgeSeeder] playervendors={0}",
- PlayerVendor.PlayerVendors == null ? 0 : PlayerVendor.PlayerVendors.Count);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[BridgeSeeder] census failed: " + ex);
- }
- }
-
- [Usage("seedworld")]
- [Description("Populates the shard with synthetic bridge-test accounts, houses and vendors.")]
- private static void Seed_OnCommand(CommandEventArgs e)
- {
- Run(e.Mobile, save: false);
- }
-
- [Usage("unseedworld")]
- [Description("Deletes everything created by [seedworld.")]
- private static void Unseed_OnCommand(CommandEventArgs e)
- {
- Unseed(e.Mobile);
- }
-
- private static void Report(Mobile to, string text)
- {
- Console.WriteLine("[BridgeSeeder] " + text);
-
- if (to != null)
- to.SendMessage(text);
- }
-
- private static void Run(Mobile to, bool save)
- {
- try
- {
- if (Accounting.Accounts.GetAccount(Prefix + "000") != null)
- {
- Report(to, "Seed data already present. Run [unseedworld first.");
- return;
- }
-
- var start = DateTime.UtcNow;
- var seeded = Seed();
- var elapsed = DateTime.UtcNow - start;
-
- Report(to, String.Format(
- "Seeded {0} accounts, {1} chars, {2} houses, {3} vendors in {4:F1}s.",
- seeded.AccountCount, seeded.CharCount, seeded.HouseCount,
- seeded.VendorCount, elapsed.TotalSeconds));
-
- if (save)
- {
- Report(to, "Saving world...");
- World.Save();
- Report(to, "Save complete.");
- }
- }
- catch (Exception ex)
- {
- Report(to, "FAILED: " + ex);
- }
- }
-
- private class Counts
- {
- public int AccountCount, CharCount, HouseCount, VendorCount;
- }
-
- private static Counts Seed()
- {
- var counts = new Counts();
-
- var accounts = new List();
- var owners = new List();
-
- for (int i = 0; i < Accounts; i++)
- {
- var acct = new Account(String.Format("{0}{1:000}", Prefix, i), Guid.NewGuid().ToString("N"));
- acct.DepositGold(Utility.RandomMinMax(5000, 4000000));
-
- accounts.Add(acct);
- counts.AccountCount++;
-
- for (int c = 0; c < CharsPerAccount; c++)
- {
- var pm = CreateChar(acct, c);
- acct[c] = pm;
- counts.CharCount++;
-
- if (c == 0)
- owners.Add(pm);
- }
- }
-
- // Houses. The first ActiveHouses go to accounts left active; the remainder go to
- // accounts backdated into inactivity so their houses are Condemned and will decay.
- int placed = 0;
-
- for (int i = 0; i < ActiveHouses && i < owners.Count; i++, placed++)
- {
- PlaceHouse(owners[i], placed);
- counts.HouseCount++;
- }
-
- for (int i = 0; i < DecayingHouses && (ActiveHouses + i) < owners.Count; i++, placed++)
- {
- var owner = owners[ActiveHouses + i];
- var house = PlaceHouse(owner, placed);
- counts.HouseCount++;
-
- // Condemn the account: LastLogin older than Account.InactiveDuration (180d).
- var acct = (Account)owner.Account;
- acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(200 + i);
-
- // Force the stage directly; DynamicDecay owns the model on this shard.
- var stage = DecayStages[i % DecayStages.Length];
- house.SetDynamicDecay(stage);
- house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
- }
-
- // Vendors on the first VendorHouses placed.
- var allHouses = new List();
-
- foreach (var pm in owners)
- allHouses.AddRange(BaseHouse.GetHouses(pm));
-
- for (int h = 0; h < VendorHouses && h < allHouses.Count; h++)
- {
- var house = allHouses[h];
-
- for (int v = 0; v < VendorsPerHouse; v++)
- {
- CreateVendor(house);
- counts.VendorCount++;
- }
- }
-
- return counts;
- }
-
- private static PlayerMobile CreateChar(Account acct, int slot)
- {
- var pm = new PlayerMobile
- {
- Player = true,
- AccessLevel = AccessLevel.Player,
- Name = String.Format("Seed{0}{1}", acct.Username.Substring(Prefix.Length), (char)('A' + slot)),
- Female = Utility.RandomBool(),
- Hue = Utility.RandomSkinHue(),
- Fame = Utility.RandomMinMax(0, 15000),
- Karma = Utility.RandomMinMax(-15000, 15000)
- };
-
- pm.Body = pm.Female ? 401 : 400;
-
- // Str must clear the plate requirements in EquipGear (PlateChest needs 95), or
- // BaseArmor.CanEquip refuses and the gear is left parentless for Cleanup to sweep.
- pm.RawStr = Utility.RandomMinMax(100, 125);
- pm.RawDex = Utility.RandomMinMax(25, 125);
- pm.RawInt = Utility.RandomMinMax(25, 125);
-
- pm.Hits = pm.HitsMax;
- pm.Mana = pm.ManaMax;
- pm.Stam = pm.StamMax;
-
- // Full skill sheet: the bridge's profile export reads every skill.
- for (int i = 0; i < pm.Skills.Length; i++)
- {
- pm.Skills[i].Base = Rng.Next(100) < 20 ? Utility.RandomMinMax(60, 120) : 0.0;
- pm.Skills[i].Cap = 120.0;
- }
-
- var pack = new Backpack { Movable = false };
- pm.AddItem(pack);
-
- EquipGear(pm);
-
- pm.MoveToWorld(
- new Point3D(HouseOrigin.X + Utility.RandomMinMax(-50, 50),
- HouseOrigin.Y + Utility.RandomMinMax(-50, 50), 0),
- Map.Felucca);
-
- return pm;
- }
-
- ///
- /// Gear carries AOS attribute bags. The profile exporter has to walk these, so a
- /// seeded character must have them populated or the cost measurement is meaningless.
- ///
- private static void EquipGear(PlayerMobile pm)
- {
- Equip(pm, MakeWeapon());
-
- Equip(pm, Decorate(new PlateChest()));
- Equip(pm, Decorate(new PlateLegs()));
- Equip(pm, Decorate(new PlateHelm()));
- Equip(pm, Decorate(new PlateArms()));
- Equip(pm, Decorate(new PlateGloves()));
- Equip(pm, new Boots(Utility.RandomNeutralHue()));
- Equip(pm, new Cloak(Utility.RandomNeutralHue()));
- }
-
- ///
- /// A rejected EquipItem leaves the item in World.Items with no parent, which the
- /// Cleanup pass later deletes en masse. Drop it immediately instead.
- ///
- private static void Equip(PlayerMobile pm, Item item)
- {
- if (!pm.EquipItem(item))
- {
- Console.WriteLine("[BridgeSeeder] equip rejected: {0} on {1}", item.GetType().Name, pm.Name);
- item.Delete();
- }
- }
-
- private static BaseWeapon MakeWeapon()
- {
- BaseWeapon w;
-
- switch (Utility.Random(3))
- {
- case 0: w = new Longsword(); break;
- case 1: w = new Katana(); break;
- default: w = new Broadsword(); break;
- }
-
- w.Hue = Utility.RandomNeutralHue();
- w.Attributes.WeaponDamage = Utility.RandomMinMax(10, 50);
- w.Attributes.AttackChance = Utility.RandomMinMax(5, 15);
- w.Attributes.DefendChance = Utility.RandomMinMax(5, 15);
- w.Attributes.BonusHits = Utility.RandomMinMax(1, 8);
- w.WeaponAttributes.HitLightning = Utility.RandomMinMax(10, 50);
- w.WeaponAttributes.HitLeechHits = Utility.RandomMinMax(10, 40);
-
- return w;
- }
-
- private static BaseArmor Decorate(BaseArmor a)
- {
- a.Hue = Utility.RandomNeutralHue();
- a.Attributes.BonusHits = Utility.RandomMinMax(1, 6);
- a.Attributes.LowerManaCost = Utility.RandomMinMax(1, 8);
- a.ArmorAttributes.SelfRepair = Utility.RandomMinMax(1, 5);
-
- return a;
- }
-
- private static BaseHouse PlaceHouse(PlayerMobile owner, int index)
- {
- int x = HouseOrigin.X + (index % HousesPerRow) * HouseSpacing;
- int y = HouseOrigin.Y + (index / HousesPerRow) * HouseSpacing;
-
- // Placement validation (HousePlacement.Check) is deliberately bypassed: the bridge
- // only reads serial/owner/coords/decay off these, never their terrain validity.
- var house = new SmallOldHouse(owner, 0x64);
- house.MoveToWorld(new Point3D(x, y, 0), Map.Felucca);
-
- house.BuiltOn = DateTime.UtcNow - TimeSpan.FromDays(Utility.RandomMinMax(10, 400));
- house.LastRefreshed = house.BuiltOn;
-
- if (house.Sign != null)
- house.Sign.Name = String.Format("Seed House {0}", index);
-
- return house;
- }
-
- private static void CreateVendor(BaseHouse house)
- {
- var owner = house.Owner;
-
- var vendor = new PlayerVendor(owner, house)
- {
- Name = "seed vendor",
- ShopName = String.Format("Seed Shop {0}", Utility.Random(1000))
- };
-
- vendor.MoveToWorld(
- new Point3D(house.X + Utility.RandomMinMax(-2, 2),
- house.Y + Utility.RandomMinMax(-2, 2), house.Z),
- house.Map);
-
- vendor.HoldGold = Utility.RandomMinMax(1000, 250000);
-
- for (int i = 0; i < ItemsPerVendor; i++)
- {
- Item item = MakeWeapon();
-
- // Dropping into the pack fires OnSubItemAdded, which registers a VendorItem
- // at the default price of 999; then correct the price.
- vendor.Backpack.DropItem(item);
-
- if (SetVendorItemMethod != null)
- SetVendorItemMethod.Invoke(vendor, new object[] { item, Utility.RandomMinMax(50, 75000), "" });
- }
- }
-
- private static void Unseed(Mobile to)
- {
- try
- {
- var doomed = new List();
-
- foreach (Account a in Accounting.Accounts.GetAccounts())
- {
- if (a.Username.StartsWith(Prefix, StringComparison.Ordinal))
- doomed.Add(a);
- }
-
- // Account.Delete also deletes the account's characters and their houses.
- foreach (var a in doomed)
- a.Delete();
-
- Report(to, String.Format("Removed {0} seed accounts (chars and houses included).", doomed.Count));
- }
- catch (Exception ex)
- {
- Report(to, "FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgeSweepProbe.cs b/tools/scaffolding/BridgeSweepProbe.cs
deleted file mode 100644
index b892e1c..0000000
--- a/tools/scaffolding/BridgeSweepProbe.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-using Server.Multis;
-
-namespace Server.Custom
-{
- ///
- /// Forces a house-decay transition so the decay sweep's transition detection can be
- /// observed without waiting out a real 12–24 h IDOC stage.
- ///
- /// After the bridge takes its silent baseline on ServerStarted, this bumps one condemned
- /// seeded house one stage further with SetDynamicDecay. The next decay sweep should see the
- /// level change and emit exactly one house.decay.
- ///
- /// Test scaffolding. Never deployed. Only meaningful against the seeded world.
- ///
- public static class BridgeSweepProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.SweepProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(6.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- BaseHouse target = null;
- DecayLevel current = DecayLevel.Ageless;
-
- // Find a house already decaying (not Ageless/LikeNew), so a bump is a real move.
- foreach (var h in BaseHouse.AllHouses)
- {
- if (h == null || h.Deleted)
- continue;
-
- var lvl = h.DecayLevel;
-
- if (lvl == DecayLevel.Greatly || lvl == DecayLevel.Fairly || lvl == DecayLevel.Somewhat)
- {
- target = h;
- current = lvl;
- break;
- }
- }
-
- if (target == null)
- {
- Console.WriteLine("[SweepProbe] no decaying house found to bump");
- return;
- }
-
- var next = current + 1; // e.g. Somewhat -> Fairly -> Greatly -> IDOC
-
- Console.WriteLine("[SweepProbe] bumping house 0x{0:X} from {1} to {2}",
- target.Serial.Value, current, next);
-
- target.SetDynamicDecay(next);
-
- Console.WriteLine("[SweepProbe] done; the next decay sweep should emit house.decay");
- }
- catch (Exception ex)
- {
- Console.WriteLine("[SweepProbe] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgeVendorSaleProbe.cs b/tools/scaffolding/BridgeVendorSaleProbe.cs
deleted file mode 100644
index dcbd18a..0000000
--- a/tools/scaffolding/BridgeVendorSaleProbe.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-using System;
-
-using Server.Accounting;
-using Server.Items;
-using Server.Mobiles;
-
-namespace Server.Custom
-{
- ///
- /// Fires PlayerVendorSale with real seeded-vendor data to prove the full chain: the
- /// EventSink event added by the Phase 7 patch, its args, the subscriber, and the vendor.sale
- /// JSON. It does NOT exercise the gump call site (that needs a live buyer with a NetState) —
- /// the hook line is placed at the committed-sale point in PlayerVendorBuyGump.OnResponse and
- /// is verified by a real in-game purchase.
- ///
- /// Test scaffolding. Never deployed. Read-only (invokes an event; changes no world state).
- ///
- public static class BridgeVendorSaleProbe
- {
- public static void Initialize()
- {
- if (Config.Get("Bridge.VendorSaleProbeOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- var vendors = PlayerVendor.PlayerVendors;
-
- if (vendors == null || vendors.Count == 0)
- {
- Console.WriteLine("[VendorSaleProbe] no player vendors; seed the world first");
- return;
- }
-
- // A real seeded vendor, its owner, and one of its actual priced items.
- PlayerVendor vendor = null;
- Item item = null;
- int price = 0;
-
- foreach (var v in vendors)
- {
- if (v == null || v.Deleted || v.Owner == null || v.Backpack == null)
- continue;
-
- foreach (var it in v.Backpack.Items)
- {
- var vi = v.GetVendorItem(it);
- if (vi != null)
- {
- vendor = v; item = it; price = vi.Price;
- break;
- }
- }
-
- if (vendor != null)
- break;
- }
-
- if (vendor == null)
- {
- Console.WriteLine("[VendorSaleProbe] no vendor with a priced item found");
- return;
- }
-
- // A buyer on a DIFFERENT account than the owner, so buyerAcct != ownerAcct.
- Mobile buyer = null;
- foreach (Account a in Accounting.Accounts.GetAccounts())
- {
- if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
- continue;
-
- var pm = a[0] as PlayerMobile;
- if (pm != null && pm != vendor.Owner &&
- !(vendor.Owner != null && vendor.Owner.Account == a))
- {
- buyer = pm;
- break;
- }
- }
-
- int commission = 0;
- if (vendor.IsCommission)
- commission = (int)(price * (vendor.CommissionPerc / 100));
-
- Console.WriteLine("[VendorSaleProbe] firing PlayerVendorSale: buyer={0} owner={1} item={2} price={3} commission={4}",
- buyer == null ? "?" : buyer.Name,
- vendor.Owner == null ? "?" : vendor.Owner.Name,
- item.GetType().Name, price, commission);
-
- EventSink.InvokePlayerVendorSale(
- new PlayerVendorSaleEventArgs(buyer, vendor, vendor.Owner, item, price, commission));
-
- Console.WriteLine("[VendorSaleProbe] done; watch for vendor.sale");
- }
- catch (Exception ex)
- {
- Console.WriteLine("[VendorSaleProbe] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/BridgeVendorTestProbe.cs b/tools/scaffolding/BridgeVendorTestProbe.cs
deleted file mode 100644
index 08d3dd9..0000000
--- a/tools/scaffolding/BridgeVendorTestProbe.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-using System;
-using System.Reflection;
-
-using Server.Accounting;
-using Server.Items;
-using Server.Mobiles;
-
-namespace Server.Custom
-{
- ///
- /// Spawns a reachable, houseless player vendor next to the `tester` character (wttest) and
- /// gives tester gold, so the PlayerVendorSale core patch can be exercised by a real in-game
- /// purchase. The buyer must be a non-GM — IsOwner() treats any GameMaster+ as the owner of
- /// every player vendor, so an Owner-level character can never buy.
- ///
- /// Owner is a seed_000 character, so buyer (wttest) and owner (seed_000) are different
- /// accounts — a clean cheat-detection example.
- ///
- /// Test scaffolding. Never deployed. Spawns a mobile and hands out gold; run only on the
- /// throwaway seeded world.
- ///
- public static class BridgeVendorTestProbe
- {
- private static readonly MethodInfo SetVendorItem = typeof(PlayerVendor).GetMethod(
- "SetVendorItem",
- BindingFlags.Instance | BindingFlags.NonPublic,
- null,
- new[] { typeof(Item), typeof(int), typeof(string) },
- null);
-
- public static void Initialize()
- {
- if (Config.Get("Bridge.VendorTestOnStart", false))
- EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run);
- }
-
- private static void Run()
- {
- try
- {
- var ownerAcct = Accounting.Accounts.GetAccount("seed_000") as Account;
- var owner = ownerAcct == null ? null : ownerAcct[0];
-
- var buyerAcct = Accounting.Accounts.GetAccount("wttest") as Account;
- var buyer = buyerAcct == null ? null : buyerAcct[0] as PlayerMobile;
-
- if (owner == null || buyer == null)
- {
- Console.WriteLine("[VendorTest] need seed_000 owner and wttest/tester; not found");
- return;
- }
-
- // Remove any prior test vendor (e.g. one orphaned on the Internal map).
- if (PlayerVendor.PlayerVendors != null)
- {
- var doomed = new System.Collections.Generic.List();
- foreach (var v in PlayerVendor.PlayerVendors)
- if (v != null && !v.Deleted && v.ShopName == "Bridge Test Shop")
- doomed.Add(v);
- foreach (var v in doomed)
- v.Delete();
- }
-
- // A confirmed-walkable spot where tester was already standing this session, so the
- // vendor is on solid ground and reachable. Then force tester's logout location right
- // next to it, so tester logs in beside the vendor regardless of where it was.
- var map = Map.Trammel;
- var loc = new Point3D(3533, 2546, 20); // vendor
- buyer.LogoutMap = map;
- buyer.LogoutLocation = new Point3D(3532, 2546, 20); // tester appears here
-
- var vendor = new PlayerVendor(owner, null)
- {
- Name = "test vendor",
- ShopName = "Bridge Test Shop"
- };
- vendor.MoveToWorld(loc, map);
-
- var blade = new Longsword();
- vendor.Backpack.DropItem(blade);
- if (SetVendorItem != null)
- SetVendorItem.Invoke(vendor, new object[] { blade, 100, "a test blade" });
-
- // Make sure tester can afford it.
- if (buyer.Backpack != null)
- buyer.Backpack.DropItem(new Gold(1000));
-
- Console.WriteLine(
- "[VendorTest] spawned '{0}' (owner {1}/seed_000) next to {2}/wttest at {3} on {4}; test blade = 100 gold; gave tester 1000 gold",
- vendor.ShopName, owner.Name, buyer.Name, loc, map);
- }
- catch (Exception ex)
- {
- Console.WriteLine("[VendorTest] FAILED: " + ex);
- }
- }
- }
-}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
deleted file mode 100644
index e5d0688..0000000
--- a/tools/scaffolding/README.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# Test scaffolding
-
-**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 [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 |
-|------|--------------------------|------|
-| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
-| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
-| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
-| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. |
-| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
-| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
-| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
-
-## Deploy overwrites Bridge.cfg
-
-`deploy.ps1` copies `overlay/Config/Bridge.cfg`, which deliberately omits the scaffolding flags. So **every deploy strips `SeedOnStart` / `EventProbeOnStart` / etc.** Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.)
-
-## Using them
-
-Copy both into `Scripts/Custom/`, then append the flags to `Config/Bridge.cfg`:
-
-```ini
-SeedOnStart=True
-CensusOnStart=False
-ProbeOnStart=False
-```
-
-Boot once to seed and save, then set `SeedOnStart=False`. `CensusOnStart` reports what the loaded world actually contains; `ProbeOnStart` prints timings two seconds after `ServerStarted`.
-
-Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.cfg` lacks these keys never runs the scaffolding — even if the `.cs` files are sitting in `Scripts/Custom/`. That is the safety net, not an excuse to ship them.
-
-In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
-
-## Back up `Saves/` first
-
-`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.
-
-`[unseedworld` deletes every `seed_*` account, which takes their characters and houses with it — but not necessarily their `PlayerVendor` mobiles. Restoring a backup is the reliable reset.
-
-## What the seeder had to work around
-
-Worth knowing before you trust its output:
-
-- **Plate needs strength.** `BaseArmor.CanEquip` rejects when `from.Str < strReq` (`PlateChest` needs 95). A rejected `EquipItem` leaves the item parentless, and the `Cleanup` pass later deletes it en masse. The seeder gives characters `Str` 100–125 and deletes any item whose equip is refused, rather than orphaning it.
-- **`VendorItem.Price` is get-only** and `PlayerVendor.SetVendorItem` is private. Dropping an item into a vendor's pack fires `OnSubItemAdded`, which registers the item at the default price of 999. The seeder reaches `SetVendorItem` by reflection to set a real price. Acceptable in throwaway scaffolding; do not do this in the plugin.
-- **Houses only decay when condemned.** `BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `ManualRefresh`. An active owner's newest house is `AutoRefresh` and never decays. The seeder backdates 18 accounts past `Account.InactiveDuration` (180 days) to condemn them, then forces stages with `SetDynamicDecay` — not by backdating `LastRefreshed`, because `DynamicDecay.Enabled` is true on this expansion and `GetOldDecayLevel` is unreachable.
-
-## Reference output
-
-Census after a fresh load of the seeded world:
-
-```
-[BridgeSeeder] houses=35
-[BridgeSeeder] decay Ageless 13, Slightly 3, Somewhat 7, Fairly 3, Greatly 3, IDOC 6
-[BridgeSeeder] seeded chars=150 avgEquipped=8.00 naked=0
-[BridgeSeeder] playervendors=30
-```
-
-Probe, best-of-20 on the Core thread:
-
-```
-[BridgeProbe] char.profile 0.069 ms/char 2386 bytes json
-[BridgeProbe] vitals sweep 0.223 ms for 150 chars (0.0015 ms/char)
-[BridgeProbe] decay sweep 0.007 ms for 35 houses (0.0002 ms/house)
-[BridgeProbe] economy sweep 0.001 ms for 51 accounts (supply 110,478,209 gold)
-[BridgeProbe] vendor snap 0.343 ms for 30 vendors (1200 listings)
-```
-
-Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 2–4× for a fully-kitted character.
diff --git a/tools/stub_sidecar.ps1 b/tools/stub_sidecar.ps1
deleted file mode 100644
index 8f367f8..0000000
--- a/tools/stub_sidecar.ps1
+++ /dev/null
@@ -1,45 +0,0 @@
-param(
- [int] $Port = 7788,
- [string] $Log = "$PSScriptRoot\sc_robust.log"
-)
-
-# Robust stub sidecar: survives port-in-use from a just-killed instance, and never
-# dies on a transient error. Test scaffolding only.
-
-function Say($msg) {
- for ($i = 0; $i -lt 5; $i++) {
- try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
- catch { Start-Sleep -Milliseconds 100 }
- }
-}
-
-"" | Out-File -FilePath $Log -Encoding utf8
-Say "[sidecar] starting on 127.0.0.1:$Port"
-
-$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
-$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
-
-# Wait for the port to become bindable if a prior instance is still lingering.
-$bound = $false
-for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
- try { $listener.Start(); $bound = $true }
- catch { Say "[sidecar] bind retry: $($_.Exception.Message)"; Start-Sleep -Seconds 1 }
-}
-if (-not $bound) { Say "[sidecar] could not bind $Port; giving up"; exit 1 }
-
-Say "[sidecar] listening"
-
-while ($true) {
- try {
- $client = $listener.AcceptTcpClient()
- Say "[sidecar] === shard connected ==="
- $reader = New-Object System.IO.StreamReader($client.GetStream())
- while ($null -ne ($line = $reader.ReadLine())) { Say "[sidecar] <- $line" }
- Say "[sidecar] === shard disconnected ==="
- $client.Close()
- }
- catch {
- Say "[sidecar] loop error: $($_.Exception.Message)"
- Start-Sleep -Milliseconds 300
- }
-}
diff --git a/tools/stub_sidecar_admin.ps1 b/tools/stub_sidecar_admin.ps1
deleted file mode 100644
index 3c02424..0000000
--- a/tools/stub_sidecar_admin.ps1
+++ /dev/null
@@ -1,78 +0,0 @@
-param(
- [int] $Port = 7788,
- [string] $Log = "$PSScriptRoot\sc_admin.log"
-)
-
-# Phase-1 admin write-plane harness. Connects as the sidecar, waits for the shard,
-# fires admin.* commands covering the happy paths and every guard, logs the replies.
-# Requires Bridge.cfg AdminWriteEnabled=true and the seeded world (seed_00x accounts).
-
-function Say($msg) {
- for ($i = 0; $i -lt 5; $i++) {
- try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
- catch { Start-Sleep -Milliseconds 100 }
- }
-}
-
-"" | Out-File -FilePath $Log -Encoding utf8
-Say "[admin] starting on 127.0.0.1:$Port"
-
-$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
-$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
-
-$bound = $false
-for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
- try { $listener.Start(); $bound = $true }
- catch { Start-Sleep -Seconds 1 }
-}
-if (-not $bound) { Say "[admin] could not bind"; exit 1 }
-
-Say "[admin] listening"
-$client = $listener.AcceptTcpClient()
-Say "[admin] === shard connected ==="
-
-$stream = $client.GetStream()
-$reader = New-Object System.IO.StreamReader($stream)
-$writer = New-Object System.IO.StreamWriter($stream)
-$writer.AutoFlush = $true
-
-Start-Sleep -Milliseconds 500
-
-$requests = @(
- # happy path, no target needed
- '{"kind":"admin.broadcast","reqId":"a-bcast","actor":"whitlocktech","text":"uo-link admin test broadcast"}',
- # ban an offline seed account (timed), then unban
- '{"kind":"admin.ban","reqId":"a-ban","actor":"whitlocktech","account":"seed_001","durationSec":3600,"reason":"harness test"}',
- '{"kind":"admin.unban","reqId":"a-unban","actor":"whitlocktech","account":"seed_001"}',
- # kick an offline account -> should succeed with sessions:0
- '{"kind":"admin.kick","reqId":"a-kick","actor":"whitlocktech","account":"seed_002"}',
- # floor: whitlocktech is Owner -> must be refused
- '{"kind":"admin.ban","reqId":"a-floor","actor":"whitlocktech","account":"whitlocktech"}',
- # unknown target
- '{"kind":"admin.ban","reqId":"a-unknown","actor":"whitlocktech","account":"does_not_exist"}',
- # missing actor -> refused by the shared gate
- '{"kind":"admin.ban","reqId":"a-noactor","account":"seed_003"}'
-)
-
-foreach ($r in $requests) {
- $writer.WriteLine($r)
- Say "[admin] -> $r"
- Start-Sleep -Milliseconds 400
-}
-
-# Drain greedily: block on ReadLine with an idle timeout so a buffered burst is fully read
-# (the DataAvailable-gated pattern drops the tail of a burst that a StreamReader pre-buffers).
-$stream.ReadTimeout = 2500
-try {
- while ($true) {
- $line = $reader.ReadLine()
- if ($null -eq $line) { break }
- Say "[admin] <- $line"
- }
-} catch {
- Say "[admin] read window closed (idle)"
-}
-
-Say "[admin] done"
-$client.Close()
-$listener.Stop()
diff --git a/tools/stub_sidecar_crier.ps1 b/tools/stub_sidecar_crier.ps1
deleted file mode 100644
index 2529281..0000000
--- a/tools/stub_sidecar_crier.ps1
+++ /dev/null
@@ -1,64 +0,0 @@
-param(
- [int] $Port = 7788,
- [string] $Log = "$PSScriptRoot\sc_crier.log"
-)
-
-function Say($msg) {
- for ($i = 0; $i -lt 5; $i++) {
- try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
- catch { Start-Sleep -Milliseconds 100 }
- }
-}
-
-"" | Out-File -FilePath $Log -Encoding utf8
-Say "[crier-sc] starting on 127.0.0.1:$Port"
-
-$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
-$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
-$bound = $false
-for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
- try { $listener.Start(); $bound = $true } catch { Start-Sleep -Seconds 1 }
-}
-if (-not $bound) { Say "[crier-sc] could not bind"; exit 1 }
-Say "[crier-sc] listening"
-
-$client = $listener.AcceptTcpClient()
-Say "[crier-sc] === shard connected ==="
-$stream = $client.GetStream()
-$stream.ReadTimeout = 1500
-$reader = New-Object System.IO.StreamReader($stream)
-$writer = New-Object System.IO.StreamWriter($stream)
-$writer.AutoFlush = $true
-Start-Sleep -Milliseconds 500
-
-# Blocking read with a timeout, so StreamReader-buffered lines are not missed the way
-# checking $stream.DataAvailable does.
-function Drain($seconds) {
- $deadline = (Get-Date).AddSeconds($seconds)
- while ((Get-Date) -lt $deadline) {
- try {
- $l = $reader.ReadLine()
- if ($null -ne $l) { Say "[crier-sc] <- $l" }
- } catch { Start-Sleep -Milliseconds 50 }
- }
-}
-
-function Send($obj) {
- $writer.WriteLine($obj)
- Say "[crier-sc] -> $obj"
- Drain 1.5
-}
-
-# 1. valid add
-Send '{"kind":"towncrier.add","id":"n1","lines":["Hear ye, hear ye!","The market tax is now 5 percent."],"durationSec":3600}'
-# 2. add exceeding the line cap (default 6) -> error
-Send '{"kind":"towncrier.add","id":"n2","lines":["1","2","3","4","5","6","7","8"],"durationSec":60}'
-# 3. remove the valid one
-Send '{"kind":"towncrier.remove","id":"n1"}'
-# 4. remove an unknown id -> error
-Send '{"kind":"towncrier.remove","id":"does-not-exist"}'
-
-Drain 3
-
-Say "[crier-sc] done"
-$client.Close(); $listener.Stop()
diff --git a/tools/stub_sidecar_link.ps1 b/tools/stub_sidecar_link.ps1
deleted file mode 100644
index 123eab1..0000000
--- a/tools/stub_sidecar_link.ps1
+++ /dev/null
@@ -1,66 +0,0 @@
-param(
- [int] $Port = 7788,
- [string] $Log = "$PSScriptRoot\sc_link.log"
-)
-
-function Say($msg) {
- for ($i = 0; $i -lt 5; $i++) {
- try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
- catch { Start-Sleep -Milliseconds 100 }
- }
-}
-
-"" | Out-File -FilePath $Log -Encoding utf8
-Say "[link-sc] starting on 127.0.0.1:$Port"
-
-$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
-$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
-$bound = $false
-for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
- try { $listener.Start(); $bound = $true } catch { Start-Sleep -Seconds 1 }
-}
-if (-not $bound) { Say "[link-sc] could not bind"; exit 1 }
-Say "[link-sc] listening"
-
-$client = $listener.AcceptTcpClient()
-Say "[link-sc] === shard connected ==="
-$stream = $client.GetStream()
-$reader = New-Object System.IO.StreamReader($stream)
-$writer = New-Object System.IO.StreamWriter($stream)
-$writer.AutoFlush = $true
-
-$deadline = (Get-Date).AddSeconds(20)
-$confirmed = $false
-
-while ((Get-Date) -lt $deadline) {
- if ($stream.DataAvailable) {
- $line = $reader.ReadLine()
- if ($null -eq $line) { break }
- Say "[link-sc] <- $line"
-
- # When the shard emits a link.request, extract the code and confirm it.
- if (-not $confirmed -and $line -match '"kind":"link\.request"') {
- if ($line -match '"code":"([^"]+)"') {
- $code = $Matches[1]
- $confirm = '{"kind":"link.confirm","code":"' + $code + '","websiteUserId":"web-9931"}'
- $writer.WriteLine($confirm)
- Say "[link-sc] -> $confirm"
- $confirmed = $true
- }
- }
- } else {
- Start-Sleep -Milliseconds 100
- }
-}
-
-# Second exchange: send a bad confirm to prove the error path.
-$writer.WriteLine('{"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}')
-Say '[link-sc] -> {"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}'
-$t = (Get-Date).AddSeconds(3)
-while ((Get-Date) -lt $t) {
- if ($stream.DataAvailable) { $l = $reader.ReadLine(); if ($l) { Say "[link-sc] <- $l" } }
- else { Start-Sleep -Milliseconds 100 }
-}
-
-Say "[link-sc] done"
-$client.Close(); $listener.Stop()
diff --git a/tools/stub_sidecar_request.ps1 b/tools/stub_sidecar_request.ps1
deleted file mode 100644
index c0d18a6..0000000
--- a/tools/stub_sidecar_request.ps1
+++ /dev/null
@@ -1,65 +0,0 @@
-param(
- [int] $Port = 7788,
- [string] $Log = "$PSScriptRoot\sc_request.log"
-)
-
-function Say($msg) {
- for ($i = 0; $i -lt 5; $i++) {
- try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
- catch { Start-Sleep -Milliseconds 100 }
- }
-}
-
-"" | Out-File -FilePath $Log -Encoding utf8
-Say "[req] starting on 127.0.0.1:$Port"
-
-$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
-$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
-
-$bound = $false
-for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
- try { $listener.Start(); $bound = $true }
- catch { Start-Sleep -Seconds 1 }
-}
-if (-not $bound) { Say "[req] could not bind"; exit 1 }
-
-Say "[req] listening"
-$client = $listener.AcceptTcpClient()
-Say "[req] === shard connected ==="
-
-$stream = $client.GetStream()
-$reader = New-Object System.IO.StreamReader($stream)
-$writer = New-Object System.IO.StreamWriter($stream)
-$writer.AutoFlush = $true
-
-# Give the shard a beat to send its hello, then fire the requests.
-Start-Sleep -Milliseconds 500
-
-$requests = @(
- '{"kind":"account.roster","reqId":"r-roster","account":"whitlocktech"}',
- '{"kind":"char.request","reqId":"r-darrow","account":"whitlocktech","slot":0}',
- '{"kind":"vendor.snapshot","reqId":"r-vendor","account":"seed_000"}',
- '{"kind":"char.request","reqId":"r-bad","account":"does_not_exist","slot":0}',
- '{"kind":"char.request","reqId":"r-serial","serial":"0x24C"}'
-)
-
-foreach ($r in $requests) {
- $writer.WriteLine($r)
- Say "[req] -> $r"
- Start-Sleep -Milliseconds 400
-}
-
-# Read replies for a few seconds.
-$deadline = (Get-Date).AddSeconds(8)
-while ((Get-Date) -lt $deadline) {
- if ($stream.DataAvailable) {
- $line = $reader.ReadLine()
- if ($null -ne $line) { Say "[req] <- $line" }
- } else {
- Start-Sleep -Milliseconds 100
- }
-}
-
-Say "[req] done"
-$client.Close()
-$listener.Stop()