Phase 4: character-profile request/response
BridgeProfile builds the read-models the website consumes; BridgeRequests
registers the inbound handlers. The sidecar asks, the shard answers on the Core
thread (inbound lines are marshaled through Timer.DelayCall before a handler
runs), so all of these read live world state safely.
- char.request: resolve by serial, or by account + slot, and reply with a full
profile (stats, all trained skills, worn equipment with flattened AOS mods,
resists). Works for offline characters since a logged-off mobile stays
resident until Delete.
- account.roster: light per-character summary, offline chars included.
- vendor.snapshot: every player vendor owned by an account, with held gold and
priced listings.
Each request may carry a reqId the reply echoes so the sidecar can correlate.
An unresolvable request gets a bridge.error reply rather than silence, so the
website can show a real failure instead of hanging.
Verified against the real world with a sending stub: all five requests answered,
both char lookup paths (account+slot and serial) returning the identical profile,
vendor.snapshot returning seed_000's two vendors and 80 listings, and the bad
account returning bridge.error. Two real-data findings noted in docs/PLAN.md §14:
a GM character can have skill base > cap (the website must not assume otherwise),
and the mod-flattening path still wants a genuinely kitted character to exercise
against real suffix gear.
Adds tools/stub_sidecar_request.ps1 (sends requests) and a hardened
tools/stub_sidecar.ps1 (survives reaping/rebind).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
215
overlay/Scripts/Custom/Bridge/BridgeRequests.cs
Normal file
215
overlay/Scripts/Custom/Bridge/BridgeRequests.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Inbound request/response. The sidecar asks; the shard answers. Every handler runs on the
|
||||
/// Core thread (BridgeBoot dispatches inbound lines through Timer.DelayCall first), so all of
|
||||
/// these may read live world state freely.
|
||||
///
|
||||
/// A request carries an optional "reqId" the shard echoes back, so the sidecar can correlate
|
||||
/// the reply with the request it sent. A malformed or unresolvable request gets a
|
||||
/// "bridge.error" reply rather than silence, so the website can show a real failure instead
|
||||
/// of hanging.
|
||||
/// </summary>
|
||||
public static class BridgeRequests
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("char.request", OnCharRequest);
|
||||
BridgeBoot.RegisterHandler("account.roster", OnRosterRequest);
|
||||
BridgeBoot.RegisterHandler("vendor.snapshot", OnVendorSnapshotRequest);
|
||||
}
|
||||
|
||||
private static void Fail(string reqId, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("bridge.error");
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- char.request ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a character by serial, or by account + slot, and reply with a full profile.
|
||||
/// Works for offline characters too: a logged-off mobile is still resident.
|
||||
/// </summary>
|
||||
private static void OnCharRequest(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
PlayerMobile pm = null;
|
||||
|
||||
var serialStr = BridgeJson.GetString(o, "serial");
|
||||
if (serialStr != null)
|
||||
{
|
||||
pm = ResolveSerial(serialStr) as PlayerMobile;
|
||||
|
||||
if (pm == null)
|
||||
{
|
||||
Fail(reqId, "no player with serial " + serialStr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var acctName = BridgeJson.GetString(o, "account");
|
||||
var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Fail(reqId, "unknown account");
|
||||
return;
|
||||
}
|
||||
|
||||
int slot = BridgeJson.GetInt(o, "slot", 0);
|
||||
|
||||
if (slot < 0 || slot >= acct.Length)
|
||||
{
|
||||
Fail(reqId, "slot out of range");
|
||||
return;
|
||||
}
|
||||
|
||||
pm = acct[slot] as PlayerMobile;
|
||||
|
||||
if (pm == null)
|
||||
{
|
||||
Fail(reqId, "no character in slot " + slot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
BridgeLink.Emit(BridgeProfile.BuildProfile(pm, reqId));
|
||||
}
|
||||
|
||||
// ---- account.roster ----
|
||||
|
||||
private static void OnRosterRequest(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var acctName = BridgeJson.GetString(o, "account");
|
||||
var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Fail(reqId, "unknown account");
|
||||
return;
|
||||
}
|
||||
|
||||
BridgeLink.Emit(BridgeProfile.BuildRoster(acct, reqId));
|
||||
}
|
||||
|
||||
// ---- vendor.snapshot ----
|
||||
|
||||
/// <summary>
|
||||
/// Every player vendor owned by any character on an account, with its held gold and
|
||||
/// priced listings. Enumerates PlayerVendor.PlayerVendors and matches by owner account.
|
||||
/// </summary>
|
||||
private static void OnVendorSnapshotRequest(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var acctName = BridgeJson.GetString(o, "account");
|
||||
var acct = acctName == null ? null : Accounting.Accounts.GetAccount(acctName) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Fail(reqId, "unknown account");
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = BridgeJson.Begin("vendor.snapshot");
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
sb.Str("acct", acct.Username);
|
||||
sb.Append(",\"vendors\":[");
|
||||
|
||||
bool firstVendor = true;
|
||||
var all = PlayerVendor.PlayerVendors;
|
||||
|
||||
if (all != null)
|
||||
{
|
||||
foreach (var v in all)
|
||||
{
|
||||
if (v == null || v.Deleted || v.Owner == null)
|
||||
continue;
|
||||
|
||||
if (!(v.Owner.Account is Account ownerAcct) || ownerAcct != acct)
|
||||
continue;
|
||||
|
||||
if (!firstVendor) sb.Append(',');
|
||||
firstVendor = false;
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(v.Serial.Value.ToString("X")).Append('"');
|
||||
sb.Append(",\"shopName\":");
|
||||
BridgeJson.Escape(sb, v.ShopName ?? "");
|
||||
sb.Append(",\"holdGold\":").Append(v.HoldGold);
|
||||
sb.Append(",\"ownerSerial\":\"0x").Append(v.Owner.Serial.Value.ToString("X")).Append('"');
|
||||
|
||||
var house = v.Map;
|
||||
sb.Append(",\"map\":");
|
||||
BridgeJson.Escape(sb, v.Map == null ? "" : v.Map.Name);
|
||||
sb.Append(",\"x\":").Append(v.X).Append(",\"y\":").Append(v.Y);
|
||||
|
||||
sb.Append(",\"listings\":[");
|
||||
bool firstItem = true;
|
||||
var pack = v.Backpack;
|
||||
|
||||
if (pack != null)
|
||||
{
|
||||
foreach (var item in pack.Items)
|
||||
{
|
||||
var vi = v.GetVendorItem(item);
|
||||
if (vi == null)
|
||||
continue;
|
||||
|
||||
if (!firstItem) sb.Append(',');
|
||||
firstItem = false;
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
|
||||
sb.Append(",\"itemId\":").Append(item.ItemID);
|
||||
sb.Append(",\"amount\":").Append(item.Amount);
|
||||
sb.Append(",\"price\":").Append(vi.Price);
|
||||
sb.Append(",\"forSale\":").Append(vi.IsForSale ? "true" : "false").Append('}');
|
||||
}
|
||||
}
|
||||
sb.Append("]}");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static Mobile ResolveSerial(string serialStr)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = serialStr.Trim();
|
||||
int value;
|
||||
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
value = Convert.ToInt32(s.Substring(2), 16);
|
||||
else
|
||||
value = Convert.ToInt32(s, 10);
|
||||
|
||||
return World.FindMobile(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user