From 21d5de9fd4e84961ced8af7cde9ba1e9e3b1124f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 00:58:32 -0500 Subject: [PATCH] chore: extract ServUO plugin to RunicGateway/servuo-plugins The C# ServUO plugin (overlay/, patches/, deploy.ps1, tools/) has been extracted with full history via git filter-repo into the new RunicGateway/servuo-plugins repo. This repo is now the Rust sidecar only. Rewrite the README to be sidecar-focused: related-repos table, build/run, and a deployment/compatibility section describing the runtime protocol relationship with the plugin. No source cross-references existed between the two halves, so nothing else needed repointing. Plugin repo: https://gitea.whitlocktech.com/RunicGateway/servuo-plugins --- README.md | 114 ++--- deploy.ps1 | 75 --- overlay/Config/Bridge.cfg | 116 ----- .../Custom/Bridge/BridgeAccountLink.cs | 298 ------------ .../Scripts/Custom/Bridge/BridgeAccounts.cs | 281 ----------- overlay/Scripts/Custom/Bridge/BridgeAdmin.cs | 364 -------------- overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 210 -------- overlay/Scripts/Custom/Bridge/BridgeChamps.cs | 287 ----------- overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 213 -------- overlay/Scripts/Custom/Bridge/BridgeEvents.cs | 440 ----------------- .../Scripts/Custom/Bridge/BridgeGovernance.cs | 175 ------- .../Scripts/Custom/Bridge/BridgeHousing.cs | 165 ------- overlay/Scripts/Custom/Bridge/BridgeJson.cs | 238 --------- overlay/Scripts/Custom/Bridge/BridgeLink.cs | 331 ------------- overlay/Scripts/Custom/Bridge/BridgeNews.cs | 176 ------- overlay/Scripts/Custom/Bridge/BridgePages.cs | 420 ---------------- .../Scripts/Custom/Bridge/BridgePresence.cs | 203 -------- .../Scripts/Custom/Bridge/BridgeProfile.cs | 291 ----------- .../Scripts/Custom/Bridge/BridgeRequests.cs | 215 --------- overlay/Scripts/Custom/Bridge/BridgeSocial.cs | 218 --------- overlay/Scripts/Custom/Bridge/BridgeSweeps.cs | 279 ----------- .../Scripts/Custom/Bridge/BridgeTownCrier.cs | 158 ------ overlay/Scripts/Scripts.csproj | 44 -- patches/BridgeModerationAudit.cs | 133 ----- patches/BridgeVendorSale.cs | 83 ---- patches/README.md | 59 --- patches/commandlogging-event.patch | 33 -- patches/playervendor-sale-eventsink.patch | 66 --- patches/playervendor-sale-gump.patch | 15 - tools/scaffolding/BridgeAuditProbe.cs | 71 --- tools/scaffolding/BridgeCrierProbe.cs | 58 --- tools/scaffolding/BridgeEventProbe.cs | 68 --- tools/scaffolding/BridgeLinkProbe.cs | 64 --- tools/scaffolding/BridgePageProbe.cs | 61 --- tools/scaffolding/BridgeProbe.cs | 405 ---------------- tools/scaffolding/BridgeSeeder.cs | 455 ------------------ tools/scaffolding/BridgeSweepProbe.cs | 70 --- tools/scaffolding/BridgeVendorSaleProbe.cs | 104 ---- tools/scaffolding/BridgeVendorTestProbe.cs | 98 ---- tools/scaffolding/README.md | 72 --- tools/stub_sidecar.ps1 | 45 -- tools/stub_sidecar_admin.ps1 | 78 --- tools/stub_sidecar_crier.ps1 | 64 --- tools/stub_sidecar_link.ps1 | 66 --- tools/stub_sidecar_request.ps1 | 65 --- 45 files changed, 38 insertions(+), 7506 deletions(-) delete mode 100644 deploy.ps1 delete mode 100644 overlay/Config/Bridge.cfg delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeAccounts.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeAdmin.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeBoot.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeChamps.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeConfig.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeEvents.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeGovernance.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeHousing.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeJson.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeLink.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeNews.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgePages.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgePresence.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeProfile.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeRequests.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeSocial.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeSweeps.cs delete mode 100644 overlay/Scripts/Custom/Bridge/BridgeTownCrier.cs delete mode 100644 overlay/Scripts/Scripts.csproj delete mode 100644 patches/BridgeModerationAudit.cs delete mode 100644 patches/BridgeVendorSale.cs delete mode 100644 patches/README.md delete mode 100644 patches/commandlogging-event.patch delete mode 100644 patches/playervendor-sale-eventsink.patch delete mode 100644 patches/playervendor-sale-gump.patch delete mode 100644 tools/scaffolding/BridgeAuditProbe.cs delete mode 100644 tools/scaffolding/BridgeCrierProbe.cs delete mode 100644 tools/scaffolding/BridgeEventProbe.cs delete mode 100644 tools/scaffolding/BridgeLinkProbe.cs delete mode 100644 tools/scaffolding/BridgePageProbe.cs delete mode 100644 tools/scaffolding/BridgeProbe.cs delete mode 100644 tools/scaffolding/BridgeSeeder.cs delete mode 100644 tools/scaffolding/BridgeSweepProbe.cs delete mode 100644 tools/scaffolding/BridgeVendorSaleProbe.cs delete mode 100644 tools/scaffolding/BridgeVendorTestProbe.cs delete mode 100644 tools/scaffolding/README.md delete mode 100644 tools/stub_sidecar.ps1 delete mode 100644 tools/stub_sidecar_admin.ps1 delete mode 100644 tools/stub_sidecar_crier.ps1 delete mode 100644 tools/stub_sidecar_link.ps1 delete mode 100644 tools/stub_sidecar_request.ps1 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()