diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index c1beefe..7a5ef92 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -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 (docs/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 (docs/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 diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs index d9a34e2..5e0b1ef 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs @@ -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); + } + + /// + /// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so + /// the website can reconcile a player-initiated unlink. Player-scoped (own account only), + /// so it needs no access floor. After unlinking, [link works again. + /// + public static void Unlink(Mobile m) + { + if (m == null) + return; + + var acct = m.Account as Account; + + if (acct == null) + { + m.SendMessage("Bridge: no account on this character."); + return; + } + + var existing = acct.GetTag(Tag); + if (existing == null) + { + m.SendMessage("Your account is not linked to a website account."); + return; + } + + acct.RemoveTag(Tag); + DropCodesFor(acct.Username); // drop any pending codes so nothing dangles + + BridgeLink.Emit(BridgeJson.Begin("account.unlinked") + .Str("origin", "in-game") + .Str("account", acct.Username) + .Str("websiteUserId", existing) + .Str("char", m.Name) + .End()); + + m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing); + } + // ---- inbound link.confirm ---- private static void OnLinkConfirm(Dictionary o) diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs new file mode 100644 index 0000000..e076eab --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Net; + +using Server.Accounting; +using Server.Misc; + +namespace Server.Custom.Bridge +{ + /// + /// The account provisioning plane (docs/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 (docs/ADMIN_CONTROLS.md §5): authorization lives on + /// the website; the shard trusts the loopback + token socket and a required "actor" field. + /// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is + /// never unlinkable from the web. The whole create plane is opt-in via SignupMode / + /// AccountCreateEnabled. + /// + public static class BridgeAccounts + { + private const string Tag = "WebsiteUserId"; + + // Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an + // in-game one (AccountHandler.cs). Kept local because that array is private. + private static readonly char[] ForbiddenChars = + { + '<', '>', ':', '"', '/', '\\', '|', '?', '*', ' ' + }; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("account.create", OnCreate); + BridgeBoot.RegisterHandler("account.unlink", OnUnlink); + } + + // ---- account.create ---- + + /// + /// Creates a game account and links it to the given website user. Refused unless the + /// signup mode allows website creation. Enforces the same username/password character + /// safety and per-IP cap as ServUO's in-game create path; the password never leaves the + /// process in any reply, audit, or log. + /// + private static void OnCreate(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var actor = BridgeJson.GetString(o, "actor"); + const string action = "create"; + + if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game) + { + Err(reqId, action, "signups disabled for this mode"); + return; + } + + if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0) + { + Err(reqId, action, "missing actor"); + return; + } + + var account = BridgeJson.GetString(o, "account"); + var password = BridgeJson.GetString(o, "password"); + var webId = BridgeJson.GetString(o, "websiteUserId"); + var ipStr = BridgeJson.GetString(o, "ip"); + + if (String.IsNullOrEmpty(account)) + { + Err(reqId, action, "missing account"); + return; + } + + if (String.IsNullOrEmpty(password)) + { + Err(reqId, action, "missing password"); + return; + } + + if (String.IsNullOrEmpty(webId)) + { + Err(reqId, action, "missing websiteUserId"); + return; + } + + if (account.Length > BridgeConfig.AccountNameMaxLength || + password.Length > BridgeConfig.AccountPasswordMaxLength) + { + Err(reqId, action, "username or password too long"); + return; + } + + if (!IsSafeUsername(account) || !IsSafePassword(password)) + { + Err(reqId, action, "invalid username/password"); + return; + } + + // Collision: the only correct resolution of a website/in-game race for a name. + if (Accounts.GetAccount(account) != null) + { + Err(reqId, action, "account already exists"); + return; + } + + // Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is + // exempt in IPLimiter, so accepting it would silently bypass the cap. + IPAddress ip; + bool haveIp = TryParseIp(ipStr, out ip); + + if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip))) + { + Err(reqId, action, "client ip required"); + return; + } + + if (haveIp && !AccountHandler.CanCreate(ip)) + { + Err(reqId, action, "ip account limit reached"); + return; + } + + // Create + link. new Account self-registers (Accounts.Add) and hashes the password per + // the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an + // in-game first-login does; the tag persists on the next world save. + var acct = new Account(account, password); + + if (haveIp) + acct.LogAccess(ip); + + acct.SetTag(Tag, webId); + + Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}", + actor, account, webId, haveIp ? ip.ToString() : "-"); + + BridgeLink.Emit(AuditBegin(action, actor, account) + .Str("websiteUserId", webId) + .End()); + + var sb = BridgeJson.Begin("account.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("account", account).Str("websiteUserId", webId); + BridgeLink.Emit(sb.End()); + } + + // ---- account.unlink ---- + + /// + /// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the + /// Owner floor keeps a protected staff account unreachable from the web. + /// + private static void OnUnlink(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var actor = BridgeJson.GetString(o, "actor"); + const string action = "unlink"; + + if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0) + { + Err(reqId, action, "missing actor"); + return; + } + + var acct = BridgeAdmin.ResolveTargetAccount(o); + if (acct == null) + { + Err(reqId, action, "unknown or accountless target"); + return; + } + + if (BridgeAdmin.Protected(acct)) + { + Err(reqId, action, "target is protected staff; refused"); + return; + } + + var existing = acct.GetTag(Tag); + if (existing == null) + { + Err(reqId, action, "not linked"); + return; + } + + acct.RemoveTag(Tag); + + Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})", + actor, acct.Username, existing); + + BridgeLink.Emit(AuditBegin(action, actor, acct.Username) + .Str("websiteUserId", existing) + .End()); + + var sb = BridgeJson.Begin("account.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action).Str("account", acct.Username); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static void Err(string reqId, string action, string reason) + { + var sb = BridgeJson.Begin("account.error"); + if (reqId != null) sb.Str("reqId", reqId); + if (action != null) sb.Str("action", action); + sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + /// + /// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to + /// admin.audit. Never carries the password. + /// + private static System.Text.StringBuilder AuditBegin(string action, string actor, string target) + { + return BridgeJson.Begin("account.audit") + .Str("origin", "web") + .Str("action", action) + .Str("actor", "web:" + actor) + .Str("target", target); + } + + /// Mirrors the username safety rules in AccountHandler.CreateAccount. + private static bool IsSafeUsername(string un) + { + if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith(".")) + return false; + + for (int i = 0; i < un.Length; i++) + { + char c = un[i]; + if (c < 0x20 || c >= 0x7F || IsForbidden(c)) + return false; + } + + return true; + } + + /// Mirrors the password safety rules in AccountHandler.CreateAccount. + private static bool IsSafePassword(string pw) + { + for (int i = 0; i < pw.Length; i++) + { + char c = pw[i]; + if (c < 0x20 || c >= 0x7F) + return false; + } + + return true; + } + + private static bool IsForbidden(char c) + { + for (int i = 0; i < ForbiddenChars.Length; i++) + if (c == ForbiddenChars[i]) + return true; + + return false; + } + + private static bool TryParseIp(string s, out IPAddress ip) + { + ip = null; + + if (String.IsNullOrEmpty(s)) + return false; + + return IPAddress.TryParse(s.Trim(), out ip); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs index 1171713..b82d893 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs @@ -283,9 +283,10 @@ namespace Server.Custom.Bridge /// /// 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. /// - private static Account ResolveTargetAccount(Dictionary o) + public static Account ResolveTargetAccount(Dictionary o) { var serialStr = BridgeJson.GetString(o, "serial"); if (serialStr != null) @@ -301,9 +302,10 @@ namespace Server.Custom.Bridge /// /// 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. /// - private static bool Protected(Account acct) + public static bool Protected(Account acct) { var lvl = acct.AccessLevel; diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 80e6082..4af7e27 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -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; } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 627e5e4..4ddd13e 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -2,6 +2,18 @@ using System; namespace Server.Custom.Bridge { + /// + /// Which side may mint game accounts. Governs the bridge's inbound account.create verb; + /// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts) + /// the operator pairs with this (docs/PROTOCOL_2.md §2). + /// + public enum SignupMode + { + Website, // website is the account authority; in-game auto-create should be off + Game, // game server is the authority; account.create is refused + Hybrid // either side may create + } + /// /// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there /// reads as "Bridge.Port" here. @@ -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 (docs/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 (docs/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 (docs/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(); + } + + /// + /// The bridge governs only the account.create verb; ServUO's in-game first-login + /// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves + /// disagree is quietly broken (website-only that still auto-creates in game, or a mode + /// that expects in-game creation with it switched off), so surface the contradiction + /// loudly rather than silently doing the permissive thing. + /// + private static void WarnOnSignupMismatch() + { + var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true); + + if (Signup == SignupMode.Website && autoCreate) + Console.WriteLine( + "[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; " + + "an in-game login of any new name still mints an account. Set it false for website-only."); + else if (Signup == SignupMode.Game && !autoCreate) + Console.WriteLine( + "[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; " + + "in-game creation is off and account.create is refused, so no account can be created."); + else if (Signup == SignupMode.Hybrid && !autoCreate) + Console.WriteLine( + "[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; " + + "in-game first-login creation is off. Only website account.create will work."); + } + + /// + /// Parses a SignupMode name, case-insensitively, falling back to + /// on anything unrecognized so a typo can never open provisioning wider than intended. + /// + private static SignupMode ParseSignupMode(string value, SignupMode fallback) + { + SignupMode parsed; + if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) && + Enum.IsDefined(typeof(SignupMode), parsed)) + return parsed; + + Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback); + return fallback; } /// @@ -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); } } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs b/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs new file mode 100644 index 0000000..c2b33b6 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeGovernance.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; + +using Server.Engines.CityLoyalty; + +namespace Server.Custom.Bridge +{ + /// + /// The town-governor stream (docs/PROTOCOL_2.md §10.2). In modern ServUO the "mayor of a + /// town" is the Governor in the City Loyalty System (King Blackthorn's governance): each of + /// the governed cities has a Governor, a GovernorElect, and an Election. None of these raises + /// an EventSink, so — like and — the set + /// is polled and each city emits `city.update` only when its signature changes. Governors turn + /// over on the order of weeks, so a slow sweep (default 5 min) is ample. + /// + /// The wire model is uniform with the rest of Part B: a full-state `city.update` upsert, with + /// "the governor changed" derived sidecar-side by comparing to the stored board — rather than a + /// discrete from→to event, which a sidecar reconnect (cache cleared, full re-emit) would + /// otherwise fire spuriously for every city. + /// + /// Gated on CityLoyaltySystem.Enabled: a shard running its own town system emits nothing here. + /// + public static class BridgeGovernance + { + private static Timer _timer; + + // City enum value -> last-emitted signature. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted; + private static bool _warnedDisabled; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.CitySweepSeconds), + CitySweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("cities(enabled={0} sweeps={1} emitted={2} tracked={3})", + CityLoyaltySystem.Enabled, _sweeps, _emitted, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + CitySweep(); + } + + private static void CitySweep() + { + try + { + _sweeps++; + + if (!CityLoyaltySystem.Enabled || CityLoyaltySystem.Cities == null) + { + if (!_warnedDisabled) + { + Console.WriteLine("[Bridge] city loyalty disabled; governor stream idle."); + _warnedDisabled = true; + } + return; + } + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + foreach (var city in CityLoyaltySystem.Cities) + { + if (city == null) + continue; + + var sig = Signature(city); + + int key = (int)city.City; + + string prior; + if (_last.TryGetValue(key, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[key] = sig; + BridgeLink.Emit(WriteCity(city)); + _emitted++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] city sweep threw: {0}", ex.Message); + } + } + + // The volatile fields: governor, governor-elect, and the election phase / candidate count. + private static string Signature(CityLoyaltySystem city) + { + var gov = city.Governor == null ? 0 : city.Governor.Serial.Value; + var elect = city.GovernorElect == null ? 0 : city.GovernorElect.Serial.Value; + + var e = city.Election; + var phase = ElectionPhase(e); + var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count; + + return String.Concat( + gov.ToString(), "|", elect.ToString(), "|", phase, "|", candidates.ToString()); + } + + private static string WriteCity(CityLoyaltySystem city) + { + var e = city.Election; + var phase = ElectionPhase(e); + var candidates = (e == null || e.Candidates == null) ? 0 : e.Candidates.Count; + + var sb = BridgeJson.Begin("city.update") + .Str("city", city.City.ToString()) + .Str("electionPhase", phase) + .Num("candidates", candidates); + + sb.Actor("governor", city.Governor); + sb.Actor("governorElect", city.GovernorElect); + + if (e != null && e.Ongoing) + sb.Str("autoPickAt", e.AutoPickGovernor.ToUniversalTime().ToString("o")); + + return sb.End(); + } + + /// Folds the election state into one of: none / nominate / vote / pending. + private static string ElectionPhase(CityElection e) + { + if (e == null) + return "none"; + + if (e.CanNominate()) + return "nominate"; + + if (e.CanVote()) + return "vote"; + + if (e.Ongoing) + return "pending"; + + return "none"; + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeHousing.cs b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs new file mode 100644 index 0000000..bca9d1c --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Server.Multis; + +namespace Server.Custom.Bridge +{ + /// + /// The housing registry (docs/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay + /// *transitions*; this is the complementary *board*: one row per house with owner, location, + /// region, co-owners, value, and current decay level, so the website can render an owner→houses + /// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit + /// house.update only when a house's signature changes, and house.remove when a house is gone. + /// + /// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the + /// registry is owner→houses; `price` is the house's placement value, not a sale listing. + /// + public static class BridgeHousing + { + private static Timer _timer; + + // house serial -> last-emitted signature. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted, _removed; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds), + HouseSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})", + _sweeps, _emitted, _removed, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + HouseSweep(); + } + + private static void HouseSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + var seen = new HashSet(); + + foreach (var house in BaseHouse.AllHouses) + { + if (house == null || house.Deleted) + continue; + + seen.Add(house.Serial); + + var level = house.DecayLevel; // computed getter — read once + var sig = Signature(house, level); + + string prior; + if (_last.TryGetValue(house.Serial, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[house.Serial] = sig; + BridgeLink.Emit(WriteHouse(house, level)); + _emitted++; + } + + var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList(); + foreach (var serial in gone) + { + _last.Remove(serial); + BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End()); + _removed++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message); + } + } + + private static string Signature(BaseHouse house, DecayLevel level) + { + var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value; + var region = house.Region; + var regionName = region == null ? "" : (region.Name ?? ""); + var sign = house.Sign; + var name = sign == null ? "" : (sign.GetName() ?? ""); + var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count; + + return String.Concat( + ownerSerial.ToString(), "|", + level.ToString(), "|", + regionName, "|", + name, "|", + coOwners.ToString(), "|", + house.Price.ToString()); + } + + private static string WriteHouse(BaseHouse house, DecayLevel level) + { + var sb = BridgeJson.Begin("house.update") + .Ser("serial", house.Serial) + .Str("decay", level.ToString()) + .Num("price", house.Price) + .Str("map", house.Map == null ? null : house.Map.Name) + .Num("x", house.X).Num("y", house.Y).Num("z", house.Z); + + var sign = house.Sign; + if (sign != null) + sb.Str("name", sign.GetName()); + + var region = house.Region; + if (region != null) + sb.Str("region", region.Name); + + sb.Actor("owner", house.Owner); + + sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count); + sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count); + + sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o")); + sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o")); + + return sb.End(); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs index 21a4d27..1b19135 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs @@ -76,6 +76,46 @@ namespace Server.Custom.Bridge return sb; } + /// + /// Writes a nested actor object: serial, name, account (when there is one), the linked + /// webId (when the account is linked), and the player flag. A `null` mobile writes null. + /// The richer counterpart to BridgeEvents' internal writer, used by the Part B streams so a + /// guild leader / joiner / governor can be attributed to a site user without a lookup. + /// + public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m) + { + sb.Append(",\"").Append(name).Append("\":"); + + if (m == null) + { + sb.Append("null"); + return sb; + } + + sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + + sb.Append(",\"name\":"); + Escape(sb, m.Name ?? ""); + + var acct = m.Account as Accounting.Account; + if (acct != null) + { + sb.Append(",\"acct\":"); + Escape(sb, acct.Username); + + var webId = BridgeAccountLink.WebIdFor(acct); + if (webId != null) + { + sb.Append(",\"webId\":"); + Escape(sb, webId); + } + } + + sb.Append(",\"player\":").Append(m.Player ? "true" : "false"); + sb.Append('}'); + return sb; + } + /// Closes the object. The trailing newline is the frame delimiter. public static string End(this StringBuilder sb) { diff --git a/overlay/Scripts/Custom/Bridge/BridgeNews.cs b/overlay/Scripts/Custom/Bridge/BridgeNews.cs new file mode 100644 index 0000000..c766406 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeNews.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; + +using Server.Mobiles; +using Server.Services.TownCryer; + +namespace Server.Custom.Bridge +{ + /// + /// Website news articles pushed into the modern Town Cryer News gump + /// (docs/PROTOCOL_2.md §16). Distinct from BridgeTownCrier, which drives the scrolling-crier + /// announcement lines (GlobalTownCrierEntryList). Here the full article — title, body (HTML), + /// image, and a "more info" URL — becomes a TownCryerNewsEntry in TownCryerSystem.NewsEntries, + /// which the stock news gumps already render (they branch on TextDefinition.Number, so string + /// content needs no gump change). + /// + /// No stock edit: NewsEntries is a public mutable list, so we insert/remove directly and keep + /// our own id -> entry map, leaving the stock entries untouched. On add we also proclaim just + /// the title through the existing crier say path (default on), so players hear it in-world. + /// + /// Everything runs on the Core thread (inbound lines are marshaled through Timer.DelayCall), + /// which is required to touch the shared news list and to send crier packets. + /// + public static class BridgeNews + { + // A neutral scroll gump when the website supplies no image. + private const int DefaultImage = 0x64E; + + // Website id -> the news entry we created for it, so a later remove/replace can find it. + private static readonly Dictionary _ours = + new Dictionary(StringComparer.Ordinal); + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("news.add", OnAdd); + BridgeBoot.RegisterHandler("news.remove", OnRemove); + } + + private static void OnAdd(Dictionary o) + { + var id = BridgeJson.GetString(o, "id"); + + if (id == null) + { + Reply("news.error", null, "missing id"); + return; + } + + var list = TownCryerSystem.NewsEntries; + if (list == null) + { + Reply("news.error", id, "town cryer unavailable"); + return; + } + + var title = BridgeJson.GetString(o, "title"); + if (String.IsNullOrEmpty(title)) + { + Reply("news.error", id, "missing title"); + return; + } + + var body = BridgeJson.GetString(o, "body") ?? ""; + var url = BridgeJson.GetString(o, "url"); + int image = BridgeJson.GetInt(o, "image", DefaultImage); + + // announce defaults to true (proclaim the title in-world); "announce":false suppresses it. + bool announce = true; + object rawAnnounce; + if (o.TryGetValue("announce", out rawAnnounce) && rawAnnounce is bool) + announce = (bool)rawAnnounce; + + if (title.Length > BridgeConfig.NewsMaxTitleLength) + title = title.Substring(0, BridgeConfig.NewsMaxTitleLength); + if (body.Length > BridgeConfig.NewsMaxBodyLength) + body = body.Substring(0, BridgeConfig.NewsMaxBodyLength); + + try + { + // Replace an existing id in place: drop the old entry first. + TownCryerNewsEntry old; + if (_ours.TryGetValue(id, out old) && old != null) + { + list.Remove(old); + _ours.Remove(id); + } + else if (_ours.Count >= BridgeConfig.NewsMaxExternal) + { + Reply("news.error", id, "too many news entries"); + return; + } + + var entry = new TownCryerNewsEntry( + new TextDefinition(title), + new TextDefinition(body), + image, + null, + url); + + list.Insert(0, entry); // newest first, as the gump reads top-down + _ours[id] = entry; + + if (announce) + Announce(title); + + Reply("news.ok", id, null); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] news.add threw: {0}", ex.Message); + Reply("news.error", id, "internal error"); + } + } + + private static void OnRemove(Dictionary o) + { + var id = BridgeJson.GetString(o, "id"); + + if (id == null) + { + Reply("news.error", null, "missing id"); + return; + } + + TownCryerNewsEntry entry; + if (!_ours.TryGetValue(id, out entry)) + { + Reply("news.error", id, "unknown id"); + return; + } + + _ours.Remove(id); + + try + { + var list = TownCryerSystem.NewsEntries; + if (list != null && entry != null) + list.Remove(entry); + + Reply("news.ok", id, null); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] news.remove threw: {0}", ex.Message); + Reply("news.error", id, "internal error"); + } + } + + /// Proclaims a single line — the article title — through the town criers. + private static void Announce(string title) + { + try + { + GlobalTownCrierEntryList.Instance.AddEntry( + new[] { title }, + TimeSpan.FromSeconds(BridgeConfig.NewsAnnounceDurationSec)); + } + catch (Exception ex) + { + // A failed proclamation must not fail the news add — the article is already posted. + Console.WriteLine("[Bridge] news announce threw: {0}", ex.Message); + } + } + + private static void Reply(string kind, string id, string reason) + { + var sb = BridgeJson.Begin(kind); + if (id != null) sb.Str("id", id); + if (reason != null) sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgePresence.cs b/overlay/Scripts/Custom/Bridge/BridgePresence.cs new file mode 100644 index 0000000..d0a50f9 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgePresence.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; + +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// The presence stream (docs/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts: + /// + /// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted + /// on a sweep but only when it changes, so the site has a live "N online" + /// plus a change history without a firehose of identical frames. + /// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap + /// per-player movement signal PLAN.md §5.6 recommends over Movement. + /// + /// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the + /// same population the vitals sweep already walks; counting them by map and region is a handful + /// of field reads. region.enter is filtered to players. + /// + public static class BridgePresence + { + private static Timer _timer; + + // Signature of the last-emitted snapshot, so an unchanged population emits nothing. + private static string _lastSig; + + private static long _sweeps, _emitted, _regionEnters; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.OnEnterRegion += OnEnterRegion; + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + // Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the + // current population within one sweep. + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _lastSig = null; + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds), + PresenceSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})", + _sweeps, _emitted, _regionEnters); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + PresenceSweep(); + } + + private static void PresenceSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + int total = 0; + var byFacet = new SortedDictionary(StringComparer.Ordinal); + var byRegion = new SortedDictionary(StringComparer.Ordinal); + + foreach (var m in World.Mobiles.Values) + { + var pm = m as PlayerMobile; + + if (pm == null || pm.NetState == null || pm.Deleted) + continue; + + total++; + + var facet = pm.Map == null ? "Internal" : pm.Map.Name; + Bump(byFacet, facet); + + var region = pm.Region; + var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name; + Bump(byRegion, regionName); + } + + var sig = Signature(total, byFacet, byRegion); + if (sig == _lastSig) + return; // population unchanged since last emit + + _lastSig = sig; + BridgeLink.Emit(WriteOnline(total, byFacet, byRegion)); + _emitted++; + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message); + } + } + + private static void Bump(IDictionary map, string key) + { + int n; + map[key] = map.TryGetValue(key, out n) ? n + 1 : 1; + } + + private static string Signature(int total, SortedDictionary byFacet, SortedDictionary byRegion) + { + var sb = new System.Text.StringBuilder(); + sb.Append(total); + foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value); + sb.Append('#'); + foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value); + return sb.ToString(); + } + + private static string WriteOnline(int total, SortedDictionary byFacet, SortedDictionary byRegion) + { + var sb = BridgeJson.Begin("presence.online").Num("count", total); + + WriteCounts(sb, "byFacet", byFacet); + WriteCounts(sb, "byRegion", byRegion); + + return sb.End(); + } + + /// Writes a nested object of {name: count} pairs. + private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary counts) + { + sb.Append(",\"").Append(field).Append("\":{"); + + bool first = true; + foreach (var kv in counts) + { + if (!first) + sb.Append(','); + first = false; + + BridgeJson.Escape(sb, kv.Key); + sb.Append(':').Append(kv.Value); + } + + sb.Append('}'); + } + + // ---- real-time region transitions ---- + + private static void OnEnterRegion(OnEnterRegionEventArgs e) + { + try + { + if (e == null || e.From == null || !e.From.Player) + return; + + var from = e.OldRegion; + var to = e.NewRegion; + + // Only meaningful when the named region actually changed. + var fromName = from == null ? null : from.Name; + var toName = to == null ? null : to.Name; + if (String.Equals(fromName, toName, StringComparison.Ordinal)) + return; + + var sb = BridgeJson.Begin("region.enter") + .Str("from", fromName) + .Str("to", toName) + .Str("map", e.From.Map == null ? null : e.From.Map.Name); + + sb.Actor("who", e.From); + + BridgeLink.Emit(sb.End()); + _regionEnters++; + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message); + } + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs index 7e12197..4e08ede 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs @@ -98,9 +98,55 @@ namespace Server.Custom.Bridge } sb.Append(']'); + WriteTitles(sb, m); + return sb.End(); } + /// + /// The titles a character holds (docs/PROTOCOL_2.md §10.3). `selected` is the index into + /// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed + /// display titles (may be absent). `reward` is the raw reward-title list — an entry may be + /// a cliloc number (as a string) or a literal string; resolve clilocs website-side. + /// + private static void WriteTitles(StringBuilder sb, PlayerMobile m) + { + sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle); + + var fameKarma = m.FameKarmaTitle; + if (!String.IsNullOrEmpty(fameKarma)) + { + sb.Append(",\"fameKarma\":"); + BridgeJson.Escape(sb, fameKarma); + } + + var skill = m.PaperdollSkillTitle; + if (!String.IsNullOrEmpty(skill)) + { + sb.Append(",\"skill\":"); + BridgeJson.Escape(sb, skill); + } + + sb.Append(",\"reward\":["); + var rewards = m.RewardTitles; + if (rewards != null) + { + bool first = true; + for (int i = 0; i < rewards.Count; i++) + { + var r = rewards[i]; + if (r == null) + continue; + + if (!first) sb.Append(','); + first = false; + + BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture)); + } + } + sb.Append("]}"); + } + private static bool IsGearLayer(Layer layer) { switch (layer) diff --git a/overlay/Scripts/Custom/Bridge/BridgeSocial.cs b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs new file mode 100644 index 0000000..2bd1b1b --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeSocial.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Server.Guilds; + +namespace Server.Custom.Bridge +{ + /// + /// The guild stream (docs/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink: + /// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and + /// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So, + /// exactly like , the roster is polled: enumerate BaseGuild.List each + /// tick, fold each guild to a small signature, and emit `guild.update` only when it changes. + /// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`. + /// + /// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and- + /// so joined" feed does not wait for the next sweep. A membership change also moves the board + /// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in + /// the next `guild.update`; per-member leave events would need a core tap and are a later + /// refinement (§10.1). + /// + /// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a + /// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every + /// guild, would look like every guild being created at once. + /// + public static class BridgeSocial + { + private static Timer _timer; + + // guild id -> last-emitted signature. An id absent here has never been emitted (or the cache + // was cleared on reconnect), so its next sweep counts as a change. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted, _removed, _joins; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.JoinGuild += OnJoinGuild; + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds), + GuildSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})", + _sweeps, _emitted, _removed, _joins, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + GuildSweep(); + } + + private static void GuildSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + var seen = new HashSet(); + + foreach (var bg in BaseGuild.List.Values) + { + var g = bg as Guild; + + // Skip disbanded guilds (leader gone): they linger in the list until cleaned up, + // and treating them as absent lets the "gone" pass below emit guild.remove. + if (g == null || g.Disbanded) + continue; + + seen.Add(g.Id); + + var sig = Signature(g); + + string prior; + if (_last.TryGetValue(g.Id, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[g.Id] = sig; + BridgeLink.Emit(WriteGuild(g)); + _emitted++; + } + + // Anything tracked last sweep but not seen now has disbanded or been removed. + var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList(); + foreach (var id in gone) + { + _last.Remove(id); + BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End()); + _removed++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message); + } + } + + // The volatile fields that define a meaningful change: name, abbreviation, leader, member + // count, the member set (order-independent serial sum), and alliance. + private static string Signature(Guild g) + { + long memberSum = 0; + int count = 0; + + var members = g.Members; + if (members != null) + { + for (int i = 0; i < members.Count; i++) + { + var m = members[i]; + if (m == null) + continue; + count++; + unchecked { memberSum += (uint)m.Serial.Value; } + } + } + + var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value; + + return String.Concat( + g.Name ?? "", "|", + g.Abbreviation ?? "", "|", + leaderSerial.ToString(), "|", + count.ToString(), "|", + memberSum.ToString(), "|", + g.Alliance == null ? "" : (g.AllianceName ?? "")); + } + + private static string WriteGuild(Guild g) + { + int online = 0, count = 0; + var members = g.Members; + if (members != null) + { + for (int i = 0; i < members.Count; i++) + { + var m = members[i]; + if (m == null) + continue; + count++; + if (m.NetState != null) + online++; + } + } + + var sb = BridgeJson.Begin("guild.update") + .Num("id", g.Id) + .Str("name", g.Name) + .Str("abbr", g.Abbreviation) + .Num("members", count) + .Num("online", online) + .Str("alliance", g.Alliance == null ? null : g.AllianceName); + + sb.Actor("leader", g.Leader); + + return sb.End(); + } + + // ---- real-time join ---- + + private static void OnJoinGuild(JoinGuildEventArgs e) + { + try + { + if (e == null || e.Mobile == null) + return; + + var g = e.Guild as Guild; + + var sb = BridgeJson.Begin("guild.join"); + if (g != null) + sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation); + sb.Actor("who", e.Mobile); + BridgeLink.Emit(sb.End()); + _joins++; + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message); + } + } + } +}