diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index 7a60eeb..949273c 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -19,6 +19,11 @@ StatSweepSeconds=30 DecaySweepSeconds=60 EconomySweepSeconds=300 +# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this +# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a +# support queue; the full open queue is also available on demand via pages.snapshot. +PageSweepSeconds=5 + # Shown to a player when they run [link. The website page where they enter the code. LinkUrl=https://yoursite/link diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index b6fda4e..4456653 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -159,6 +159,7 @@ namespace Server.Custom.Bridge case "reload": BridgeConfig.Load(); BridgeSweeps.Rearm(); + BridgePages.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; @@ -181,6 +182,7 @@ namespace Server.Custom.Bridge BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped, BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors); e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index ece27ff..480ed47 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -17,6 +17,7 @@ namespace Server.Custom.Bridge public static int StatSweepSeconds { get; private set; } public static int DecaySweepSeconds { get; private set; } public static int EconomySweepSeconds { get; private set; } + public static int PageSweepSeconds { get; private set; } public static string LinkUrl { get; private set; } @@ -50,6 +51,9 @@ namespace Server.Custom.Bridge StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30); DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60); EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300); + PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5); + if (PageSweepSeconds < 1) + PageSweepSeconds = 1; LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link"); diff --git a/overlay/Scripts/Custom/Bridge/BridgePages.cs b/overlay/Scripts/Custom/Bridge/BridgePages.cs new file mode 100644 index 0000000..ff3a541 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgePages.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Server.Accounting; +using Server.Engines.Help; + +namespace Server.Custom.Bridge +{ + /// + /// The in-game help-page (support ticket) queue, surfaced to the website. + /// + /// A player who uses the Help button creates a — sender, message, + /// type, location, and (once a staffer claims it) a handler. The queue lives in memory with + /// no EventSink, so — like the sweeps in — it is polled and diffed: + /// a page appearing emits page.new, one leaving emits page.closed, and a + /// handled-state change emits page.updated. The whole open queue is also available on + /// demand via the pages.snapshot request (the backfill a dashboard uses on connect). + /// + /// A page is keyed by its sender's serial: the queue enforces one page per sender + /// (PageQueue.Contains), so the sender serial is a stable page id. + /// + /// Inbound page.respond delivers a message to the player exactly as an in-game staff + /// response does (online: a gump now; offline: queued for next login), optionally closing the + /// page; page.close just removes it. Both run on the Core thread. + /// + public static class BridgePages + { + private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static Timer _timer; + private static long _sweeps, _new, _closed, _updated; + + private struct Seen + { + public long SentMs; + public bool Handled; + } + + // sender serial -> last-seen page identity. Core-thread only. + private static readonly Dictionary _seen = new Dictionary(); + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot); + BridgeBoot.RegisterHandler("page.respond", OnRespond); + BridgeBoot.RegisterHandler("page.close", OnClose); + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + Baseline(); + Rearm(); + } + + /// Stops and recreates the poll timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + if (_timer != null) + { + _timer.Stop(); + _timer = null; + } + + var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds); + _timer = Timer.DelayCall(iv, iv, Sweep); + } + + public static string Status() + { + return String.Format( + "pages(sweeps={0} new={1} closed={2} updated={3} open={4})", + _sweeps, _new, _closed, _updated, _seen.Count); + } + + /// Seeds _seen from the current queue without emitting, so a restart/reload does not + /// re-announce pages already open. + private static void Baseline() + { + _seen.Clear(); + + foreach (PageEntry e in PageQueue.List) + { + if (e == null || e.Sender == null) + continue; + + _seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null }; + } + } + + // ---- poll ---- + + private static void Sweep() + { + try + { + _sweeps++; + + var cur = new Dictionary(); + + foreach (PageEntry e in PageQueue.List) + { + if (e == null || e.Sender == null) + continue; + + cur[e.Sender.Serial.Value] = e; + } + + // Closed: keys in _seen no longer present. + if (_seen.Count > 0) + { + List gone = null; + + foreach (var kv in _seen) + { + if (!cur.ContainsKey(kv.Key)) + { + if (gone == null) + gone = new List(); + gone.Add(kv.Key); + } + } + + if (gone != null) + { + foreach (var id in gone) + { + EmitClosed(id); + _seen.Remove(id); + } + } + } + + // New / replaced / handled-state changed. + foreach (var kv in cur) + { + var e = kv.Value; + long sentMs = ToMs(e.Sent); + bool handled = e.Handler != null; + + Seen prev; + if (!_seen.TryGetValue(kv.Key, out prev)) + { + EmitNew(e); + } + else if (prev.SentMs != sentMs) + { + // Same sender, different page (they cancelled and re-paged within a tick). + EmitClosed(kv.Key); + EmitNew(e); + } + else if (prev.Handled != handled) + { + EmitUpdated(e); + } + + _seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled }; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message); + } + } + + // ---- outbound ---- + + private static void EmitNew(PageEntry e) + { + _new++; + var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e)); + AppendPageTail(sb, e); + BridgeLink.Emit(sb.End()); + } + + private static void EmitUpdated(PageEntry e) + { + _updated++; + var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e)); + AppendPageTail(sb, e); + BridgeLink.Emit(sb.End()); + } + + private static void EmitClosed(int serial) + { + _closed++; + BridgeLink.Emit(BridgeJson.Begin("page.closed") + .Str("pageId", "0x" + serial.ToString("X")) + .End()); + } + + private static void OnSnapshot(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + + var sb = BridgeJson.Begin("pages.list"); + if (reqId != null) + sb.Str("reqId", reqId); + + sb.Append(",\"pages\":["); + + bool first = true; + foreach (PageEntry e in PageQueue.List) + { + if (e == null || e.Sender == null) + continue; + + if (!first) + sb.Append(','); + first = false; + + sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"'); + AppendPageTail(sb, e); + sb.Append('}'); + } + + sb.Append(']'); + BridgeLink.Emit(sb.End()); + } + + /// Appends every page field except the opening pageId, each comma-prefixed, so it + /// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array). + private static void AppendPageTail(StringBuilder sb, PageEntry e) + { + sb.Append(",\"sender\":"); + WriteSender(sb, e.Sender); + + sb.Str("type", e.Type.ToString()); + sb.Str("message", e.Message ?? ""); + sb.Str("map", e.PageMap == null ? null : e.PageMap.Name); + sb.Num("x", e.PageLocation.X); + sb.Num("y", e.PageLocation.Y); + sb.Num("z", e.PageLocation.Z); + sb.Num("sentMs", ToMs(e.Sent)); + sb.Bool("handled", e.Handler != null); + + if (e.Handler != null) + sb.Str("handler", e.Handler.Name); + } + + private static void WriteSender(StringBuilder sb, Mobile m) + { + if (m == null) + { + sb.Append("null"); + return; + } + + sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + sb.Append(",\"name\":"); + BridgeJson.Escape(sb, m.Name ?? ""); + + var acct = m.Account as Account; + if (acct != null) + { + sb.Append(",\"acct\":"); + BridgeJson.Escape(sb, acct.Username); + + var webId = BridgeAccountLink.WebIdFor(acct); + if (webId != null) + { + sb.Append(",\"webId\":"); + BridgeJson.Escape(sb, webId); + } + } + + sb.Append('}'); + } + + // ---- inbound ---- + + /// page.respond {reqId, pageId, message, close?}. Delivers a staff response to the + /// player and optionally closes the page. + private static void OnRespond(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var pageId = BridgeJson.GetString(o, "pageId"); + var message = BridgeJson.GetString(o, "message"); + bool close = GetBool(o, "close"); + + if (String.IsNullOrEmpty(message)) + { + Err(reqId, "respond", pageId, "missing message"); + return; + } + + var e = Find(pageId); + if (e == null) + { + Err(reqId, "respond", pageId, "unknown page"); + return; + } + + try + { + // Same delivery as an in-game staff response: a null handler shows as "Staff". + // ResponseEntry queues for an offline sender; SendGump delivers now if online. + var re = new ResponseEntry(e.Sender, null, message); + re.SendGump(); + + if (close) + PageQueue.Remove(e); + + Ok(reqId, "respond", pageId, close); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message); + Err(reqId, "respond", pageId, "internal error"); + } + } + + /// page.close {reqId, pageId}. Removes the page from the queue. + private static void OnClose(Dictionary o) + { + var reqId = BridgeJson.GetString(o, "reqId"); + var pageId = BridgeJson.GetString(o, "pageId"); + + var e = Find(pageId); + if (e == null) + { + Err(reqId, "close", pageId, "unknown page"); + return; + } + + try + { + PageQueue.Remove(e); + Ok(reqId, "close", pageId, true); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message); + Err(reqId, "close", pageId, "internal error"); + } + } + + private static void Ok(string reqId, string action, string pageId, bool closed) + { + var sb = BridgeJson.Begin("page.ok"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action); + if (pageId != null) sb.Str("pageId", pageId); + sb.Bool("closed", closed); + BridgeLink.Emit(sb.End()); + } + + private static void Err(string reqId, string action, string pageId, string reason) + { + var sb = BridgeJson.Begin("page.error"); + if (reqId != null) sb.Str("reqId", reqId); + sb.Str("action", action); + if (pageId != null) sb.Str("pageId", pageId); + sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + // ---- helpers ---- + + private static PageEntry Find(string pageId) + { + int serial; + if (!TryParseSerial(pageId, out serial)) + return null; + + foreach (PageEntry e in PageQueue.List) + { + if (e != null && e.Sender != null && e.Sender.Serial.Value == serial) + return e; + } + + return null; + } + + private static string PageId(PageEntry e) + { + return "0x" + e.Sender.Serial.Value.ToString("X"); + } + + private static long ToMs(DateTime dt) + { + return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds; + } + + private static bool GetBool(Dictionary o, string key) + { + object v; + if (o != null && o.TryGetValue(key, out v) && v is bool) + return (bool)v; + return false; + } + + private static bool TryParseSerial(string s, out int value) + { + value = 0; + if (String.IsNullOrEmpty(s)) + return false; + + try + { + s = s.Trim(); + if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + value = Convert.ToInt32(s.Substring(2), 16); + else + value = Convert.ToInt32(s, 10); + + return true; + } + catch + { + return false; + } + } + } +} diff --git a/tools/scaffolding/BridgePageProbe.cs b/tools/scaffolding/BridgePageProbe.cs new file mode 100644 index 0000000..3e518d5 --- /dev/null +++ b/tools/scaffolding/BridgePageProbe.cs @@ -0,0 +1,61 @@ +using System; + +using Server.Accounting; +using Server.Engines.Help; + +namespace Server.Custom +{ + /// + /// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages + /// (poll/stream + snapshot + respond/close) can be verified without a game client. + /// + /// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client: + /// each entry's InternalTimer would remove the page on its first tick because the sender has + /// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop + /// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot, + /// respond, close — then operates on real queue entries. + /// + /// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart. + /// + public static class BridgePageProbe + { + public static void Initialize() + { + if (Config.Get("Bridge.PageProbeOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run); + } + + private static void Run() + { + try + { + Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck); + Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug); + Console.WriteLine("[PageProbe] done"); + } + catch (Exception ex) + { + Console.WriteLine("[PageProbe] FAILED: " + ex); + } + } + + private static void Enqueue(string account, string message, PageType type) + { + var acct = Accounting.Accounts.GetAccount(account) as Account; + var sender = acct == null ? null : acct[0]; + + if (sender == null) + { + Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account); + return; + } + + var entry = new PageEntry(sender, message, type); + PageQueue.Enqueue(entry); + entry.Stop(); // keep it in the queue despite the offline sender + + Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})", + type, account, sender.Serial.Value); + } + } +}