chore: extract ServUO plugin to RunicGateway/servuo-plugins #11
114
README.md
114
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
|
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
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
| `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). |
|
||||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
| `.gitea/workflows/release.yml` | Builds + releases the sidecar binary (Linux + Windows) on every merge to `main`. |
|
||||||
| `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. |
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
|
cd sidecar
|
||||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
|
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 |
|
## Deployment & compatibility
|
||||||
|------:|-------|
|
|
||||||
| 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** |
|
|
||||||
|
|
||||||
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
|
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
|
||||||
`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:
|
repos. Canonical spec:
|
||||||
|
[PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §5/§7 and
|
||||||
- the DLL landed in `Scripts/bin/Release/` while the core loads `Scripts.dll` from the base directory, and
|
[INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md).
|
||||||
- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
|
Because a wedged or absent sidecar cannot stall the shard, either side can be deployed or restarted
|
||||||
|
independently.
|
||||||
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`.
|
|
||||||
|
|||||||
75
deploy.ps1
75
deploy.ps1
@@ -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)."
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<string, Pending> _codes =
|
|
||||||
new Dictionary<string, Pending>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// account -> last [link time, to rate-limit code spam.
|
|
||||||
private static readonly Dictionary<string, DateTime> _lastRequest =
|
|
||||||
new Dictionary<string, DateTime>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads the linked website id for an account, or null. Used to enrich events.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<string, object> 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<string>();
|
|
||||||
|
|
||||||
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<string>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Misc;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnCreate(Dictionary<string, object> 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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnUnlink(Dictionary<string, object> 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
|
|
||||||
/// admin.audit. Never carries the password.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Network;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="Protected"/>: 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>Disconnects every live session of the target account. Target by serial or account.</summary>
|
|
||||||
private static void OnKick(Dictionary<string, object> 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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnBan(Dictionary<string, object> 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<string, object> 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<string, object> 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 ----
|
|
||||||
|
|
||||||
/// <summary>Common gate: the write plane must be enabled and an actor must be present.</summary>
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads and length-clamps the optional reason string.</summary>
|
|
||||||
private static string Reason(Dictionary<string, object> 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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static Account ResolveTargetAccount(Dictionary<string, object> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static int KickAccountSessions(Account acct)
|
|
||||||
{
|
|
||||||
var doomed = new List<NetState>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Commands;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeBoot
|
|
||||||
{
|
|
||||||
private static readonly Dictionary<string, Action<Dictionary<string, object>>> _handlers =
|
|
||||||
new Dictionary<string, Action<Dictionary<string, object>>>(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Handlers run on the Core thread. They may touch the world freely.</summary>
|
|
||||||
public static void RegisterHandler(string kind, Action<Dictionary<string, object>> handler)
|
|
||||||
{
|
|
||||||
_handlers[kind] = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnServerStarted()
|
|
||||||
{
|
|
||||||
_bootId = Guid.NewGuid().ToString("N");
|
|
||||||
BridgeLink.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Core thread, one call per inbound line.</summary>
|
|
||||||
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<Dictionary<string, object>> 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<string, object> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The champion-spawn stream. Like the streams in <see cref="BridgeSweeps"/>, 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.
|
|
||||||
/// </summary>
|
|
||||||
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<Serial, string> _last = new Dictionary<Serial, string>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
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<Serial>();
|
|
||||||
|
|
||||||
foreach (var s in World.Items.Values.OfType<ChampionSpawn>())
|
|
||||||
{
|
|
||||||
if (s.Deleted)
|
|
||||||
continue;
|
|
||||||
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var s in World.Items.Values.OfType<MiniChamp>())
|
|
||||||
{
|
|
||||||
if (s.Deleted)
|
|
||||||
continue;
|
|
||||||
Track(seen, s.Serial, SigMini(s), WriteMini(s));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var b in World.Mobiles.Values.OfType<BaseSeaChampion>())
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Records a spawn as seen and emits it only if its signature changed since last sweep.</summary>
|
|
||||||
private static void Track(HashSet<Serial> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Re-readable at runtime via `[bridge reload`.</summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
|
|
||||||
/// on anything unrecognized so a typo can never open provisioning wider than intended.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>Writes a nested actor object: serial, name, and account when there is one.</summary>
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ValidVendorPurchase is a validation-stage hook, not a committed sale. Treat as
|
|
||||||
/// "attempted". Total is AmountPerUnit times the stack size, not AmountPerUnit.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Veto hook: FastWalkEventArgs.Blocked gates the move. Read only. The args carry only
|
|
||||||
/// a NetState, and NetState.Mobile can be null mid-handshake.
|
|
||||||
/// </summary>
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Engines.CityLoyalty;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="BridgeChamps"/> and <see cref="BridgeSocial"/> — 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeGovernance
|
|
||||||
{
|
|
||||||
private static Timer _timer;
|
|
||||||
|
|
||||||
// City enum value -> last-emitted signature.
|
|
||||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Folds the election state into one of: none / nominate / vote / pending.</summary>
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeHousing
|
|
||||||
{
|
|
||||||
private static Timer _timer;
|
|
||||||
|
|
||||||
// house serial -> last-emitted signature.
|
|
||||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
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<Serial>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>Opens an object and writes the `t` and `kind` fields.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Serial as the canonical "0x1A2B" string the sidecar keys on.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static Dictionary<string, object> Parse(string line)
|
|
||||||
{
|
|
||||||
if (String.IsNullOrEmpty(line))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_parser == null)
|
|
||||||
{
|
|
||||||
_parser = new JavaScriptSerializer();
|
|
||||||
_parser.MaxJsonLength = 1 << 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _parser.Deserialize<Dictionary<string, object>>(line);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetString(Dictionary<string, object> 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static List<string> GetStringList(Dictionary<string, object> o, string key)
|
|
||||||
{
|
|
||||||
var result = new List<string>();
|
|
||||||
|
|
||||||
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<string, object> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The loopback link to the Rust sidecar. Newline-delimited JSON, bidirectional.
|
|
||||||
///
|
|
||||||
/// Threading contract, which the whole bridge depends on:
|
|
||||||
///
|
|
||||||
/// * <see cref="Emit"/> 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeLink
|
|
||||||
{
|
|
||||||
private static readonly ConcurrentQueue<string> _outbound = new ConcurrentQueue<string>();
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
|
||||||
/// a few seconds of buffering, not half a minute.
|
|
||||||
/// </summary>
|
|
||||||
private const int MaxBackoffMs = 5000;
|
|
||||||
|
|
||||||
private static int _depth;
|
|
||||||
private static long _sent, _dropped, _received, _connects, _writeErrors;
|
|
||||||
|
|
||||||
/// <summary>Raised on the <b>Core thread</b>, one call per inbound line.</summary>
|
|
||||||
public static event Action<string> InboundLine;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raised on the <b>Core thread</b> 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.
|
|
||||||
/// </summary>
|
|
||||||
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); } }
|
|
||||||
/// <summary>Total successful connections, so the first connect counts as 1.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Services.TownCryer;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<string, TownCryerNewsEntry> _ours =
|
|
||||||
new Dictionary<string, TownCryerNewsEntry>(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<string, object> 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<string, object> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Proclaims a single line — the article title — through the town criers.</summary>
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Engines.Help;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The in-game help-page (support ticket) queue, surfaced to the website.
|
|
||||||
///
|
|
||||||
/// A player who uses the Help button creates a <see cref="PageEntry"/> — 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 <see cref="BridgeSweeps"/> — it is polled and diffed:
|
|
||||||
/// a page appearing emits <c>page.new</c>, one leaving emits <c>page.closed</c>, and a
|
|
||||||
/// handled-state change emits <c>page.updated</c>. The whole open queue is also available on
|
|
||||||
/// demand via the <c>pages.snapshot</c> 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 <c>page.respond</c> 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; <c>page.close</c> just removes it. Both run on the Core thread.
|
|
||||||
/// </summary>
|
|
||||||
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<int, Seen> _seen = new Dictionary<int, Seen>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the poll timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Seeds _seen from the current queue without emitting, so a restart/reload does not
|
|
||||||
/// re-announce pages already open.</summary>
|
|
||||||
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<int, PageEntry>();
|
|
||||||
|
|
||||||
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<int> gone = null;
|
|
||||||
|
|
||||||
foreach (var kv in _seen)
|
|
||||||
{
|
|
||||||
if (!cur.ContainsKey(kv.Key))
|
|
||||||
{
|
|
||||||
if (gone == null)
|
|
||||||
gone = new List<int>();
|
|
||||||
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<string, object> 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>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).</summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
|
|
||||||
/// player and optionally closes the page.</summary>
|
|
||||||
private static void OnRespond(Dictionary<string, object> 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>page.close {reqId, pageId}. Removes the page from the queue.</summary>
|
|
||||||
private static void OnClose(Dictionary<string, object> 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<string, object> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
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<string, int>(StringComparer.Ordinal);
|
|
||||||
var byRegion = new SortedDictionary<string, int>(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<string, int> map, string key)
|
|
||||||
{
|
|
||||||
int n;
|
|
||||||
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> 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<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
|
||||||
{
|
|
||||||
var sb = BridgeJson.Begin("presence.online").Num("count", total);
|
|
||||||
|
|
||||||
WriteCounts(sb, "byFacet", byFacet);
|
|
||||||
WriteCounts(sb, "byRegion", byRegion);
|
|
||||||
|
|
||||||
return sb.End();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Writes a nested object of {name: count} pairs.</summary>
|
|
||||||
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Mobiles;
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnCharRequest(Dictionary<string, object> 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<string, object> 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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
private static void OnVendorSnapshotRequest(Dictionary<string, object> 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
using Server.Guilds;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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 <see cref="BridgeChamps"/>, 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.
|
|
||||||
/// </summary>
|
|
||||||
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<int, string> _last = new Dictionary<int, string>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
||||||
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<int>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<Serial, DecayLevel> _decayState =
|
|
||||||
new Dictionary<Serial, DecayLevel>();
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timers from current config. Called by `[bridge reload`.</summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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 ----
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Runs each sweep once, now. For `[bridge sweepnow`.</summary>
|
|
||||||
public static void SweepOnce()
|
|
||||||
{
|
|
||||||
VitalsSweep();
|
|
||||||
DecaySweep();
|
|
||||||
EconomySweep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public static class BridgeTownCrier
|
|
||||||
{
|
|
||||||
// Website id -> the entry we created for it, so a later remove can find it.
|
|
||||||
private static readonly Dictionary<string, TownCrierEntry> _entries =
|
|
||||||
new Dictionary<string, TownCrierEntry>(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<string, object> 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<string, object> 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<string>();
|
|
||||||
|
|
||||||
foreach (var kv in _entries)
|
|
||||||
{
|
|
||||||
if (kv.Value == null || kv.Value.Expired)
|
|
||||||
doomed.Add(kv.Key);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var id in doomed)
|
|
||||||
_entries.Remove(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net48</TargetFramework>
|
|
||||||
<OutputType>Library</OutputType>
|
|
||||||
<AssemblyName>Scripts</AssemblyName>
|
|
||||||
<RootNamespace>Server</RootNamespace>
|
|
||||||
<AppendTargetFrameworkToOutputPath>False</AppendTargetFrameworkToOutputPath>
|
|
||||||
<GenerateAssemblyInfo>False</GenerateAssemblyInfo>
|
|
||||||
<UseVSHostingProcess>False</UseVSHostingProcess>
|
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
|
||||||
<UseWindowsForms>false</UseWindowsForms>
|
|
||||||
<Platforms>x64</Platforms>
|
|
||||||
</PropertyGroup>
|
|
||||||
<!--
|
|
||||||
Conditioned on Configuration alone, not Configuration|Platform. ScriptCompiler.Compile
|
|
||||||
runs `dotnet build Scripts/Scripts.csproj -c Release` with no Platform, so Platform
|
|
||||||
defaults to AnyCPU. Under the old Platform-qualified conditions that meant OutputPath
|
|
||||||
was unset (the DLL landed in Scripts/bin/Release/ while the core loads Scripts.dll from
|
|
||||||
the base directory) and DefineConstants were empty (XmlSpawner compiled its non-ServUO
|
|
||||||
branches). The net effect was that runtime script compilation silently had no effect.
|
|
||||||
-->
|
|
||||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
|
|
||||||
<OutputPath>..\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;DEBUG;NEWTIMERS;ServUO</DefineConstants>
|
|
||||||
<DebugType>embedded</DebugType>
|
|
||||||
</PropertyGroup>
|
|
||||||
<PropertyGroup Condition="'$(Configuration)'=='Release'">
|
|
||||||
<OutputPath>..\</OutputPath>
|
|
||||||
<DefineConstants>TRACE;NEWTIMERS;ServUO</DefineConstants>
|
|
||||||
<DebugType>none</DebugType>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="System.Web" />
|
|
||||||
<!-- JavaScriptSerializer, for parsing inbound sidecar commands. See BridgeJson. -->
|
|
||||||
<Reference Include="System.Web.Extensions" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Server\Server.csproj" />
|
|
||||||
<ProjectReference Include="..\Ultima\Ultima.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="System.Data.DataSetExtensions" Version="4.5.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>[bcast / [bc / [b — a staff broadcast. Its message is the command args.</summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Pulls the account from a CommandLogging.Format rendering's trailing "('account')".</summary>
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server;
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Custom.Bridge;
|
|
||||||
|
|
||||||
namespace Server.Custom.Bridge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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/<name>.patch # dry run
|
|
||||||
git apply patches/<name>.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 <servuo root>
|
|
||||||
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<Mobile,string> 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 <servuo root>
|
|
||||||
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.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
--- a/Scripts/Commands/Logging.cs
|
|
||||||
+++ b/Scripts/Commands/Logging.cs
|
|
||||||
@@ -75,16 +75,27 @@
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ /// <summary>
|
|
||||||
+ /// 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.
|
|
||||||
+ /// </summary>
|
|
||||||
+ public static event Action<Mobile, string> 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;
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Commands;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Custom.Bridge;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Engines.Help;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<PlayerVendor>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Best-of-N: the minimum is the least noisy estimate of true cost.</summary>
|
|
||||||
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<PlayerMobile> CollectSeededChars()
|
|
||||||
{
|
|
||||||
var list = new List<PlayerMobile>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reports what the seeded world actually contains after a load, rather than what
|
|
||||||
/// the seeder intended to create. Read-only.
|
|
||||||
/// </summary>
|
|
||||||
private static void Census()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var byLevel = new Dictionary<DecayLevel, int>();
|
|
||||||
|
|
||||||
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<Account>();
|
|
||||||
var owners = new List<PlayerMobile>();
|
|
||||||
|
|
||||||
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<BaseHouse>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A rejected EquipItem leaves the item in World.Items with no parent, which the
|
|
||||||
/// Cleanup pass later deletes en masse. Drop it immediately instead.
|
|
||||||
/// </summary>
|
|
||||||
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<Account>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
using Server.Multis;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
using Server.Accounting;
|
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
|
||||||
|
|
||||||
namespace Server.Custom
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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<PlayerVendor>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user