feat(pages): help-page (support) queue — stream, snapshot, respond, close
Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the
website.
- BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds,
default 5s) and diffed, keyed by sender serial (one page per player) ->
page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list;
page.respond delivers a staff reply to the player (online: a gump now; offline:
queued for next login; shows as "Staff") and can close; page.close removes it.
- BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll,
status reports it.
- sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close.
- INTEGRATION.md: page events (§4) and endpoints (§6).
- tools/scaffolding/BridgePageProbe.cs: gated headless verification.
Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits
page.new for both and page.closed on removal, respond -> 200, close removes the
page, unknown page -> 404.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
420
overlay/Scripts/Custom/Bridge/BridgePages.cs
Normal file
420
overlay/Scripts/Custom/Bridge/BridgePages.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Engines.Help;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The in-game help-page (support ticket) queue, surfaced to the website.
|
||||
///
|
||||
/// A player who uses the Help button creates a <see cref="PageEntry"/> — sender, message,
|
||||
/// type, location, and (once a staffer claims it) a handler. The queue lives in memory with
|
||||
/// no EventSink, so — like the sweeps in <see cref="BridgeSweeps"/> — it is polled and diffed:
|
||||
/// a page appearing emits <c>page.new</c>, one leaving emits <c>page.closed</c>, and a
|
||||
/// handled-state change emits <c>page.updated</c>. The whole open queue is also available on
|
||||
/// demand via the <c>pages.snapshot</c> request (the backfill a dashboard uses on connect).
|
||||
///
|
||||
/// A page is keyed by its sender's serial: the queue enforces one page per sender
|
||||
/// (PageQueue.Contains), so the sender serial is a stable page id.
|
||||
///
|
||||
/// Inbound <c>page.respond</c> delivers a message to the player exactly as an in-game staff
|
||||
/// response does (online: a gump now; offline: queued for next login), optionally closing the
|
||||
/// page; <c>page.close</c> just removes it. Both run on the Core thread.
|
||||
/// </summary>
|
||||
public static class BridgePages
|
||||
{
|
||||
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Timer _timer;
|
||||
private static long _sweeps, _new, _closed, _updated;
|
||||
|
||||
private struct Seen
|
||||
{
|
||||
public long SentMs;
|
||||
public bool Handled;
|
||||
}
|
||||
|
||||
// sender serial -> last-seen page identity. Core-thread only.
|
||||
private static readonly Dictionary<int, Seen> _seen = new Dictionary<int, Seen>();
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot);
|
||||
BridgeBoot.RegisterHandler("page.respond", OnRespond);
|
||||
BridgeBoot.RegisterHandler("page.close", OnClose);
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
Baseline();
|
||||
Rearm();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the poll timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
if (_timer != null)
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds);
|
||||
_timer = Timer.DelayCall(iv, iv, Sweep);
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format(
|
||||
"pages(sweeps={0} new={1} closed={2} updated={3} open={4})",
|
||||
_sweeps, _new, _closed, _updated, _seen.Count);
|
||||
}
|
||||
|
||||
/// <summary>Seeds _seen from the current queue without emitting, so a restart/reload does not
|
||||
/// re-announce pages already open.</summary>
|
||||
private static void Baseline()
|
||||
{
|
||||
_seen.Clear();
|
||||
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e == null || e.Sender == null)
|
||||
continue;
|
||||
|
||||
_seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- poll ----
|
||||
|
||||
private static void Sweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
var cur = new Dictionary<int, PageEntry>();
|
||||
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e == null || e.Sender == null)
|
||||
continue;
|
||||
|
||||
cur[e.Sender.Serial.Value] = e;
|
||||
}
|
||||
|
||||
// Closed: keys in _seen no longer present.
|
||||
if (_seen.Count > 0)
|
||||
{
|
||||
List<int> gone = null;
|
||||
|
||||
foreach (var kv in _seen)
|
||||
{
|
||||
if (!cur.ContainsKey(kv.Key))
|
||||
{
|
||||
if (gone == null)
|
||||
gone = new List<int>();
|
||||
gone.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (gone != null)
|
||||
{
|
||||
foreach (var id in gone)
|
||||
{
|
||||
EmitClosed(id);
|
||||
_seen.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New / replaced / handled-state changed.
|
||||
foreach (var kv in cur)
|
||||
{
|
||||
var e = kv.Value;
|
||||
long sentMs = ToMs(e.Sent);
|
||||
bool handled = e.Handler != null;
|
||||
|
||||
Seen prev;
|
||||
if (!_seen.TryGetValue(kv.Key, out prev))
|
||||
{
|
||||
EmitNew(e);
|
||||
}
|
||||
else if (prev.SentMs != sentMs)
|
||||
{
|
||||
// Same sender, different page (they cancelled and re-paged within a tick).
|
||||
EmitClosed(kv.Key);
|
||||
EmitNew(e);
|
||||
}
|
||||
else if (prev.Handled != handled)
|
||||
{
|
||||
EmitUpdated(e);
|
||||
}
|
||||
|
||||
_seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- outbound ----
|
||||
|
||||
private static void EmitNew(PageEntry e)
|
||||
{
|
||||
_new++;
|
||||
var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e));
|
||||
AppendPageTail(sb, e);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void EmitUpdated(PageEntry e)
|
||||
{
|
||||
_updated++;
|
||||
var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e));
|
||||
AppendPageTail(sb, e);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void EmitClosed(int serial)
|
||||
{
|
||||
_closed++;
|
||||
BridgeLink.Emit(BridgeJson.Begin("page.closed")
|
||||
.Str("pageId", "0x" + serial.ToString("X"))
|
||||
.End());
|
||||
}
|
||||
|
||||
private static void OnSnapshot(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
var sb = BridgeJson.Begin("pages.list");
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Append(",\"pages\":[");
|
||||
|
||||
bool first = true;
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e == null || e.Sender == null)
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
first = false;
|
||||
|
||||
sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"');
|
||||
AppendPageTail(sb, e);
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>Appends every page field except the opening pageId, each comma-prefixed, so it
|
||||
/// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array).</summary>
|
||||
private static void AppendPageTail(StringBuilder sb, PageEntry e)
|
||||
{
|
||||
sb.Append(",\"sender\":");
|
||||
WriteSender(sb, e.Sender);
|
||||
|
||||
sb.Str("type", e.Type.ToString());
|
||||
sb.Str("message", e.Message ?? "");
|
||||
sb.Str("map", e.PageMap == null ? null : e.PageMap.Name);
|
||||
sb.Num("x", e.PageLocation.X);
|
||||
sb.Num("y", e.PageLocation.Y);
|
||||
sb.Num("z", e.PageLocation.Z);
|
||||
sb.Num("sentMs", ToMs(e.Sent));
|
||||
sb.Bool("handled", e.Handler != null);
|
||||
|
||||
if (e.Handler != null)
|
||||
sb.Str("handler", e.Handler.Name);
|
||||
}
|
||||
|
||||
private static void WriteSender(StringBuilder sb, Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||
sb.Append(",\"name\":");
|
||||
BridgeJson.Escape(sb, m.Name ?? "");
|
||||
|
||||
var acct = m.Account as Account;
|
||||
if (acct != null)
|
||||
{
|
||||
sb.Append(",\"acct\":");
|
||||
BridgeJson.Escape(sb, acct.Username);
|
||||
|
||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||
if (webId != null)
|
||||
{
|
||||
sb.Append(",\"webId\":");
|
||||
BridgeJson.Escape(sb, webId);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
/// <summary>page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
|
||||
/// player and optionally closes the page.</summary>
|
||||
private static void OnRespond(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var pageId = BridgeJson.GetString(o, "pageId");
|
||||
var message = BridgeJson.GetString(o, "message");
|
||||
bool close = GetBool(o, "close");
|
||||
|
||||
if (String.IsNullOrEmpty(message))
|
||||
{
|
||||
Err(reqId, "respond", pageId, "missing message");
|
||||
return;
|
||||
}
|
||||
|
||||
var e = Find(pageId);
|
||||
if (e == null)
|
||||
{
|
||||
Err(reqId, "respond", pageId, "unknown page");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Same delivery as an in-game staff response: a null handler shows as "Staff".
|
||||
// ResponseEntry queues for an offline sender; SendGump delivers now if online.
|
||||
var re = new ResponseEntry(e.Sender, null, message);
|
||||
re.SendGump();
|
||||
|
||||
if (close)
|
||||
PageQueue.Remove(e);
|
||||
|
||||
Ok(reqId, "respond", pageId, close);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message);
|
||||
Err(reqId, "respond", pageId, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>page.close {reqId, pageId}. Removes the page from the queue.</summary>
|
||||
private static void OnClose(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var pageId = BridgeJson.GetString(o, "pageId");
|
||||
|
||||
var e = Find(pageId);
|
||||
if (e == null)
|
||||
{
|
||||
Err(reqId, "close", pageId, "unknown page");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PageQueue.Remove(e);
|
||||
Ok(reqId, "close", pageId, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message);
|
||||
Err(reqId, "close", pageId, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Ok(string reqId, string action, string pageId, bool closed)
|
||||
{
|
||||
var sb = BridgeJson.Begin("page.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action);
|
||||
if (pageId != null) sb.Str("pageId", pageId);
|
||||
sb.Bool("closed", closed);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Err(string reqId, string action, string pageId, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("page.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action);
|
||||
if (pageId != null) sb.Str("pageId", pageId);
|
||||
sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static PageEntry Find(string pageId)
|
||||
{
|
||||
int serial;
|
||||
if (!TryParseSerial(pageId, out serial))
|
||||
return null;
|
||||
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e != null && e.Sender != null && e.Sender.Serial.Value == serial)
|
||||
return e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string PageId(PageEntry e)
|
||||
{
|
||||
return "0x" + e.Sender.Serial.Value.ToString("X");
|
||||
}
|
||||
|
||||
private static long ToMs(DateTime dt)
|
||||
{
|
||||
return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds;
|
||||
}
|
||||
|
||||
private static bool GetBool(Dictionary<string, object> o, string key)
|
||||
{
|
||||
object v;
|
||||
if (o != null && o.TryGetValue(key, out v) && v is bool)
|
||||
return (bool)v;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseSerial(string s, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (String.IsNullOrEmpty(s))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
s = s.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
value = Convert.ToInt32(s.Substring(2), 16);
|
||||
else
|
||||
value = Convert.ToInt32(s, 10);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<AppState>, Json(body): Json<Value>) ->
|
||||
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<AppState>) -> 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":<bool, optional>}. Delivers a staff response to the player.
|
||||
async fn page_respond(
|
||||
State(st): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<Value>,
|
||||
) -> 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<AppState>, Path(id): Path<String>) -> 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(
|
||||
|
||||
61
tools/scaffolding/BridgePageProbe.cs
Normal file
61
tools/scaffolding/BridgePageProbe.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Engines.Help;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages
|
||||
/// (poll/stream + snapshot + respond/close) can be verified without a game client.
|
||||
///
|
||||
/// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client:
|
||||
/// each entry's InternalTimer would remove the page on its first tick because the sender has
|
||||
/// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop
|
||||
/// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot,
|
||||
/// respond, close — then operates on real queue entries.
|
||||
///
|
||||
/// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart.
|
||||
/// </summary>
|
||||
public static class BridgePageProbe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
if (Config.Get("Bridge.PageProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck);
|
||||
Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug);
|
||||
Console.WriteLine("[PageProbe] done");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[PageProbe] FAILED: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Enqueue(string account, string message, PageType type)
|
||||
{
|
||||
var acct = Accounting.Accounts.GetAccount(account) as Account;
|
||||
var sender = acct == null ? null : acct[0];
|
||||
|
||||
if (sender == null)
|
||||
{
|
||||
Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account);
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new PageEntry(sender, message, type);
|
||||
PageQueue.Enqueue(entry);
|
||||
entry.Stop(); // keep it in the queue despite the offline sender
|
||||
|
||||
Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})",
|
||||
type, account, sender.Serial.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user