Compare commits
10 Commits
048ee000f5
...
ea12ac3f94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea12ac3f94 | ||
| e9856212ce | |||
| 0fd6b91f22 | |||
| 5cade1aa9d | |||
| 5e0b42b948 | |||
| be442d36a0 | |||
| a837edd5ee | |||
| 1244eb6c4f | |||
| dd39d524c6 | |||
| 5816c29c67 |
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.log
|
||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Runic Gateway — ServUO Plugin
|
||||
|
||||
The **C# ServUO side** of the Runic Gateway bridge. The shard emits newline-delimited JSON
|
||||
over a loopback TCP socket to the Rust **sidecar** ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)),
|
||||
which owns the WebSocket the website consumes.
|
||||
|
||||
```
|
||||
ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website
|
||||
(Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out)
|
||||
>>> THIS REPO <<< (RunicGateway/link)
|
||||
```
|
||||
|
||||
The shard never speaks WebSocket. Every world read happens on the Core thread; the socket is
|
||||
touched only by a dedicated writer thread draining a bounded queue.
|
||||
|
||||
## Documentation
|
||||
|
||||
All project documentation 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).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `overlay/` | Mirrors the ServUO server root. Everything here — and **only** this — copies over an install. |
|
||||
| `patches/` | Unified diffs against stock ServUO for files we must modify rather than add. |
|
||||
| `tools/` | Never deployed. Test scaffolding (C# probes + PowerShell stub sidecars) and anything else that must not reach a server. |
|
||||
| `deploy.ps1` | Copies `overlay/` into a server root. `-Verify` diffs instead of writing. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
|
||||
Anything under `overlay/` is authoritative. Do not edit files in the server tree directly — edit here and deploy.
|
||||
|
||||
## Sidecar & deployment
|
||||
|
||||
The Rust sidecar is the other half of the bridge and lives in **[RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)**.
|
||||
The two are deployed **together** but built **independently**:
|
||||
|
||||
- **This plugin** is deployed as *source* — `deploy.ps1` copies `overlay/` into the ServUO server
|
||||
root, and ServUO compiles it at boot (`Scripts.csproj`; see [Phase 0](#phase-0--what-it-fixes)).
|
||||
There is no separate build artifact and no CI build — it cannot be compiled standalone without the
|
||||
ServUO reference assemblies.
|
||||
- **The sidecar** is a standalone Rust binary, released from its own repo.
|
||||
|
||||
The **only** coupling is the loopback JSON protocol (the shard dials out to the sidecar on
|
||||
`127.0.0.1`). Compatibility is a **protocol** concern, not a build-order one: keep the event/command
|
||||
catalog in sync across the two repos (canonical spec: [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
|
||||
§5/§7 and [INTEGRATION.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)).
|
||||
A wedged or absent sidecar cannot stall the shard, so the plugin can be deployed before, after, or
|
||||
without the sidecar running.
|
||||
|
||||
## Deploy
|
||||
|
||||
```powershell
|
||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo -Verify # show what would change
|
||||
.\deploy.ps1 -ServerPath C:\Users\colby\Desktop\servuo # write
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
| Phase | State |
|
||||
|------:|-------|
|
||||
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
|
||||
| 1 — transport (`BridgeLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §11** |
|
||||
| 2 — event streams (`BridgeEvents`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §12** |
|
||||
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §13** |
|
||||
| 4 — request/response (`BridgeRequests`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §14** |
|
||||
| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §15** |
|
||||
| 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §16** |
|
||||
| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §17** |
|
||||
|
||||
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/`.
|
||||
|
||||
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).
|
||||
|
||||
## Phase 0 — what it fixes
|
||||
|
||||
`ScriptCompiler.Compile()` runs `dotnet build Scripts/Scripts.csproj -c Release`, prints the output, and **never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Because that build passed no `Platform`, MSBuild defaulted to `AnyCPU`, and `Scripts.csproj` gated both `OutputPath` and `DefineConstants` on `Configuration|Platform == Release|x64`. So:
|
||||
|
||||
- the DLL landed in `Scripts/bin/Release/` while the core loads `Scripts.dll` from the base directory, and
|
||||
- `TRACE;NEWTIMERS;ServUO` went undefined, so XmlSpawner compiled its non-ServUO branches.
|
||||
|
||||
Runtime script compilation therefore had no effect, silently. `overlay/Scripts/Scripts.csproj` conditions both property groups on `Configuration` alone.
|
||||
|
||||
`Server.csproj` is deliberately left alone: nothing under `Server/` uses those symbols, and giving it `OutputPath=..\` would make the boot-time build try to overwrite the running `ServUO.exe`.
|
||||
|
||||
## The plugin (Phase 1)
|
||||
|
||||
`overlay/Scripts/Custom/Bridge/`:
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `BridgeConfig.cs` | Reads `Config/Bridge.cfg` in `Configure()`, before `World.Load`. |
|
||||
| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
|
||||
| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
|
||||
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
|
||||
| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
|
||||
| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
|
||||
| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
|
||||
| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
|
||||
| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. |
|
||||
| `BridgeTownCrier.cs` | Town-crier news (Phase 6): inbound `towncrier.add` / `remove` into the global crier list, with abuse caps. |
|
||||
|
||||
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
|
||||
|
||||
## Testing
|
||||
|
||||
`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
|
||||
|
||||
```powershell
|
||||
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
|
||||
```
|
||||
|
||||
`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
|
||||
|
||||
Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar ([RunicGateway/link](https://gitea.whitlocktech.com/RunicGateway/link)) 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`.
|
||||
@@ -14,7 +14,7 @@ Port=7788
|
||||
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 docs/PLAN.md §1.
|
||||
# 0.0015 ms/char, so 1000 online players is ~1.5 ms per sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||
StatSweepSeconds=30
|
||||
DecaySweepSeconds=60
|
||||
EconomySweepSeconds=300
|
||||
@@ -29,6 +29,25 @@ ChampSweepSeconds=10
|
||||
# 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
|
||||
|
||||
@@ -39,6 +58,15 @@ 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
|
||||
@@ -57,6 +85,31 @@ 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
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Server.Custom.Bridge
|
||||
/// 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 (docs/PLAN.md §2); if the
|
||||
/// link.confirm is trusted only because the socket is loopback-only (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §2); if the
|
||||
/// sidecar ever moves off-host, gate it behind a shared secret.
|
||||
/// </summary>
|
||||
public static class BridgeAccountLink
|
||||
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
|
||||
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.
|
||||
@@ -127,6 +128,53 @@ namespace Server.Custom.Bridge
|
||||
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)
|
||||
|
||||
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Server.Custom.Bridge
|
||||
/// 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 (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
||||
/// Trust model (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
|
||||
/// these commands are gated there behind admin/moderator roles. The shard trusts the
|
||||
/// 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
|
||||
@@ -248,7 +248,7 @@ namespace Server.Custom.Bridge
|
||||
/// 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 docs/ADMIN_CONTROLS.md §5.5.
|
||||
/// 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)
|
||||
{
|
||||
@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
|
||||
|
||||
/// <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.
|
||||
/// "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>
|
||||
private static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
public static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
{
|
||||
var serialStr = BridgeJson.GetString(o, "serial");
|
||||
if (serialStr != null)
|
||||
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
|
||||
/// <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.
|
||||
/// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
|
||||
/// so the account plane (unlink) enforces the identical floor.
|
||||
/// </summary>
|
||||
private static bool Protected(Account acct)
|
||||
public static bool Protected(Account acct)
|
||||
{
|
||||
var lvl = acct.AccessLevel;
|
||||
|
||||
|
||||
@@ -161,6 +161,10 @@ namespace Server.Custom.Bridge
|
||||
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;
|
||||
@@ -173,9 +177,17 @@ namespace Server.Custom.Bridge
|
||||
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:
|
||||
@@ -186,6 +198,10 @@ namespace Server.Custom.Bridge
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ 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.
|
||||
@@ -19,6 +31,10 @@ namespace Server.Custom.Bridge
|
||||
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; }
|
||||
|
||||
@@ -27,12 +43,25 @@ namespace Server.Custom.Bridge
|
||||
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()
|
||||
@@ -60,6 +89,24 @@ namespace Server.Custom.Bridge
|
||||
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);
|
||||
@@ -67,14 +114,77 @@ namespace Server.Custom.Bridge
|
||||
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>
|
||||
@@ -95,9 +205,9 @@ namespace Server.Custom.Bridge
|
||||
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})",
|
||||
"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);
|
||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
175
overlay/Scripts/Custom/Bridge/BridgeGovernance.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
165
overlay/Scripts/Custom/Bridge/BridgeHousing.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ 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 docs/PLAN.md assumes this cost, not a
|
||||
/// every emitted event, and the measured budget in https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md assumes this cost, not a
|
||||
/// reflection serializer's.
|
||||
///
|
||||
/// Inbound JSON is parsed with JavaScriptSerializer. Commands arrive at human rates, so
|
||||
@@ -76,6 +76,46 @@ namespace Server.Custom.Bridge
|
||||
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)
|
||||
{
|
||||
|
||||
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
176
overlay/Scripts/Custom/Bridge/BridgeNews.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace Server.Custom.Bridge
|
||||
///
|
||||
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
||||
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
||||
/// a sweep. See docs/PLAN.md §1.
|
||||
/// a sweep. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1.
|
||||
/// </summary>
|
||||
public static class BridgeProfile
|
||||
{
|
||||
@@ -83,7 +83,7 @@ namespace Server.Custom.Bridge
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
|
||||
// worn equipment only — not the backpack/bank (see https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §IV.4)
|
||||
sb.Append(",\"equipment\":[");
|
||||
first = true;
|
||||
foreach (var item in m.Items)
|
||||
@@ -98,9 +98,55 @@ namespace Server.Custom.Bridge
|
||||
}
|
||||
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)
|
||||
|
||||
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
218
overlay/Scripts/Custom/Bridge/BridgeSocial.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ 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 (docs/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
||||
/// measured cost (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md §1) is why they can: at the seeded scale a full pass of all
|
||||
/// three is well under a millisecond.
|
||||
///
|
||||
/// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Server.Custom.Bridge
|
||||
/// <summary>
|
||||
/// 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 docs/ADMIN_CONTROLS.md §5.5.
|
||||
/// came from the website or a staff member in the game client. See https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/ADMIN_CONTROLS.md §5.5.
|
||||
///
|
||||
/// Two sources, mirroring how the shard records each:
|
||||
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
||||
|
||||
@@ -11,7 +11,7 @@ git apply patches/<name>.patch
|
||||
|
||||
## Phase 7 — player-vendor sale (a coupled unit)
|
||||
|
||||
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See `docs/PLAN.md` §6.
|
||||
Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §6.
|
||||
|
||||
This is the one non-drop-in piece. Apply all three together:
|
||||
|
||||
@@ -56,4 +56,4 @@ Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Sc
|
||||
|
||||
## Note on shard repairs
|
||||
|
||||
The deletions and edits described in `docs/SHARD_PREREQS.md` are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
||||
The deletions and edits described in [SHARD_PREREQS.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/SHARD_PREREQS.md) are one-time repairs to a specific broken install, not part of the bridge. They are not shipped here.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Not part of the bridge. Never deployed.** `deploy.ps1` only copies `overlay/`, so nothing here reaches a server unless you put it there by hand.
|
||||
|
||||
These two scripts produced the measured budget in `docs/PLAN.md` §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
||||
These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) §1. They are kept because those numbers should be reproducible, and because re-running the probe is the only honest way to check whether a change to the plugin's read path got more expensive.
|
||||
|
||||
| File | Server path when testing | What |
|
||||
|------|--------------------------|------|
|
||||
|
||||
Reference in New Issue
Block a user