diff --git a/docs/ADMIN_CONTROLS.md b/docs/ADMIN_CONTROLS.md
index 30af617..08345e0 100644
--- a/docs/ADMIN_CONTROLS.md
+++ b/docs/ADMIN_CONTROLS.md
@@ -140,7 +140,9 @@ It turns "a staff member must be logged into the game to see the queue" into "th
> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event.
> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored.
>
-> **Phase 1 + the bidirectional-audit slice are complete.** Remaining is downstream (website UI + moderation log) and the later Phase 2 (help-page queue) / Phase 3 work.
+> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page.
+>
+> **Phase 1 + bidirectional audit + Phase 2 are complete.** Remaining: downstream website UI (moderation log + support-queue view), then Phase 3 (mute/notes/teleport/save).
**Wire in, in order:**
diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md
index 17d46db..3a37309 100644
--- a/docs/INTEGRATION.md
+++ b/docs/INTEGRATION.md
@@ -185,6 +185,15 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|------|--------|-------|
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
+#### Help-page (support) queue
+| kind | fields | notes |
+|------|--------|-------|
+| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
+| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
+| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
+
+The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
+
---
## 5. REST — read queries
@@ -351,6 +360,31 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
by staff in the game client surfaces the same way with `origin:"in-game"`.
+### Help-page (support) queue
+
+Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
+roles, like the moderation endpoints above.
+
+```
+GET /pages # the open queue, newest state
+POST /pages/{pageId}/respond { "message":"...", "close": false }
+POST /pages/{pageId}/close
+```
+
+- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
+ event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
+ with the `page.new` / `page.updated` / `page.closed` events.
+- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
+ they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
+ `"close": true` to resolve the page in the same call. → **200** `page.ok`.
+- **close** removes the page from the queue. → **200** `page.ok`.
+- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
+
+```json
+POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
+→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
+```
+
### History (from the sidecar's database)
```
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/sidecar/src/web.rs b/sidecar/src/web.rs
index 435f8d8..4f0c9dd 100644
--- a/sidecar/src/web.rs
+++ b/sidecar/src/web.rs
@@ -63,6 +63,10 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/admin/ban", post(admin_ban))
.route("/admin/unban", post(admin_unban))
.route("/admin/broadcast", post(admin_broadcast))
+ // Help-page (support) queue: snapshot the open queue, respond to / close a page.
+ .route("/pages", get(pages_list))
+ .route("/pages/:id/respond", post(page_respond))
+ .route("/pages/:id/close", post(page_close))
// History, read from SQLite rather than the shard.
.route("/history", get(history))
.route("/economy", get(economy))
@@ -330,6 +334,45 @@ async fn admin_broadcast(State(st): State, Json(body): Json) ->
admin_call(&st, "admin.broadcast", body).await
}
+// ---- help-page queue handlers ----
+
+/// The open help-page queue, correlated on reqId. Returns a pages.list.
+async fn pages_list(State(st): State) -> impl IntoResponse {
+ let req_id = st.rpc.next_req_id();
+ let cmd = json!({"kind": "pages.snapshot", "reqId": req_id});
+ respond(st.rpc.call(&st.shard, cmd, &req_id).await)
+}
+
+/// Body: {"message":"...","close":}. Delivers a staff response to the player.
+async fn page_respond(
+ State(st): State,
+ Path(id): Path,
+ Json(body): Json,
+) -> impl IntoResponse {
+ let message = body.get("message").and_then(|m| m.as_str()).unwrap_or_default();
+ if message.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "message is required"})),
+ );
+ }
+ let close = body.get("close").and_then(|c| c.as_bool()).unwrap_or(false);
+
+ let req_id = st.rpc.next_req_id();
+ let cmd = json!({
+ "kind": "page.respond", "reqId": req_id,
+ "pageId": id, "message": message, "close": close
+ });
+ respond(st.rpc.call(&st.shard, cmd, &req_id).await)
+}
+
+/// Removes a page from the queue.
+async fn page_close(State(st): State, Path(id): Path) -> impl IntoResponse {
+ let req_id = st.rpc.next_req_id();
+ let cmd = json!({"kind": "page.close", "reqId": req_id, "pageId": id});
+ respond(st.rpc.call(&st.shard, cmd, &req_id).await)
+}
+
// ---- query handlers ----
async fn char_by_slot(
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);
+ }
+ }
+}