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:
245
overlay/Scripts/Custom/Bridge/BridgeProfile.cs
Normal file
245
overlay/Scripts/Custom/Bridge/BridgeProfile.cs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Items;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the heavy read-models the website consumes: a full character profile, an account
|
||||||
|
/// roster, and a player's vendor holdings. All read live Mobile/Item state, so all must run
|
||||||
|
/// on the Core thread — which the inbound dispatch guarantees (BridgeLink marshals every
|
||||||
|
/// inbound line through Timer.DelayCall before a handler sees it).
|
||||||
|
///
|
||||||
|
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
|
||||||
|
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
|
||||||
|
/// a sweep. See docs/PLAN.md §1.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeProfile
|
||||||
|
{
|
||||||
|
private static readonly AosAttribute[] AllAttrs =
|
||||||
|
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
|
||||||
|
|
||||||
|
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
|
||||||
|
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
|
||||||
|
|
||||||
|
private static readonly AosArmorAttribute[] AllArmorAttrs =
|
||||||
|
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
|
||||||
|
|
||||||
|
// ---- full profile ----
|
||||||
|
|
||||||
|
public static string BuildProfile(PlayerMobile m, string reqId)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("char.profile");
|
||||||
|
|
||||||
|
if (reqId != null)
|
||||||
|
sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Ser("serial", m.Serial);
|
||||||
|
sb.Str("name", m.Name);
|
||||||
|
sb.Str("title", m.Title);
|
||||||
|
sb.Num("body", m.Body.BodyID);
|
||||||
|
sb.Num("hue", m.Hue);
|
||||||
|
sb.Bool("online", m.NetState != null);
|
||||||
|
|
||||||
|
var acct = m.Account as Account;
|
||||||
|
if (acct != null)
|
||||||
|
sb.Str("acct", acct.Username);
|
||||||
|
|
||||||
|
// stats
|
||||||
|
sb.Append(",\"stats\":{");
|
||||||
|
sb.Append("\"str\":").Append(m.Str).Append(",\"dex\":").Append(m.Dex).Append(",\"int\":").Append(m.Int);
|
||||||
|
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
|
||||||
|
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
|
||||||
|
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
|
||||||
|
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
|
||||||
|
sb.Append(",\"luck\":").Append(m.Luck);
|
||||||
|
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
|
||||||
|
sb.Append(",\"fire\":").Append(m.FireResistance);
|
||||||
|
sb.Append(",\"cold\":").Append(m.ColdResistance);
|
||||||
|
sb.Append(",\"pois\":").Append(m.PoisonResistance);
|
||||||
|
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
|
||||||
|
|
||||||
|
// skills: trained only (Base > 0), to avoid ~50 zeroes per character
|
||||||
|
sb.Append(",\"skills\":[");
|
||||||
|
bool first = true;
|
||||||
|
for (int i = 0; i < m.Skills.Length; i++)
|
||||||
|
{
|
||||||
|
var s = m.Skills[i];
|
||||||
|
|
||||||
|
if (s == null || s.Base <= 0.0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
|
||||||
|
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
|
||||||
|
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
|
||||||
|
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
|
||||||
|
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
|
||||||
|
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
|
||||||
|
sb.Append(",\"equipment\":[");
|
||||||
|
first = true;
|
||||||
|
foreach (var item in m.Items)
|
||||||
|
{
|
||||||
|
if (item == null || !IsGearLayer(item.Layer))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
WriteItem(sb, item);
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGearLayer(Layer layer)
|
||||||
|
{
|
||||||
|
switch (layer)
|
||||||
|
{
|
||||||
|
case Layer.Backpack:
|
||||||
|
case Layer.Bank:
|
||||||
|
case Layer.Hair:
|
||||||
|
case Layer.FacialHair:
|
||||||
|
case Layer.Mount:
|
||||||
|
case Layer.Invalid:
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteItem(StringBuilder sb, Item item)
|
||||||
|
{
|
||||||
|
sb.Append("{");
|
||||||
|
sb.Append("\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
|
||||||
|
sb.Append(",\"layer\":\"").Append(item.Layer).Append('"');
|
||||||
|
sb.Append(",\"itemId\":").Append(item.ItemID);
|
||||||
|
sb.Append(",\"hue\":").Append(item.Hue);
|
||||||
|
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
|
||||||
|
|
||||||
|
if (item.Name != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"name\":");
|
||||||
|
BridgeJson.Escape(sb, item.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
var weapon = item as BaseWeapon;
|
||||||
|
var armor = item as BaseArmor;
|
||||||
|
|
||||||
|
if (weapon != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"weapon\":{\"minDamage\":").Append(weapon.MinDamage);
|
||||||
|
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage).Append('}');
|
||||||
|
}
|
||||||
|
else if (armor != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"armor\":{\"baseRating\":").Append(armor.BaseArmorRating).Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattened union of non-zero mods across every attribute bag
|
||||||
|
sb.Append(",\"mods\":{");
|
||||||
|
bool first = true;
|
||||||
|
|
||||||
|
if (weapon != null)
|
||||||
|
{
|
||||||
|
WriteAttrs(sb, weapon.Attributes, ref first);
|
||||||
|
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref first);
|
||||||
|
}
|
||||||
|
else if (armor != null)
|
||||||
|
{
|
||||||
|
WriteAttrs(sb, armor.Attributes, ref first);
|
||||||
|
WriteArmorAttrs(sb, armor.ArmorAttributes, ref first);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("}}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
|
||||||
|
{
|
||||||
|
if (a == null) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < AllAttrs.Length; i++)
|
||||||
|
{
|
||||||
|
int v = a[AllAttrs[i]];
|
||||||
|
if (v == 0) continue;
|
||||||
|
if (!first) sb.Append(','); first = false;
|
||||||
|
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
|
||||||
|
{
|
||||||
|
if (a == null) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < AllWeaponAttrs.Length; i++)
|
||||||
|
{
|
||||||
|
int v = a[AllWeaponAttrs[i]];
|
||||||
|
if (v == 0) continue;
|
||||||
|
if (!first) sb.Append(','); first = false;
|
||||||
|
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
|
||||||
|
{
|
||||||
|
if (a == null) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < AllArmorAttrs.Length; i++)
|
||||||
|
{
|
||||||
|
int v = a[AllArmorAttrs[i]];
|
||||||
|
if (v == 0) continue;
|
||||||
|
if (!first) sb.Append(','); first = false;
|
||||||
|
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account roster ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Light per-character summary for an account. Offline characters are included: a
|
||||||
|
/// logged-off mobile stays resident (World.Mobiles) until Delete, so its roster entry is
|
||||||
|
/// always available.
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildRoster(Account acct, string reqId)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("account.roster");
|
||||||
|
|
||||||
|
if (reqId != null)
|
||||||
|
sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Str("acct", acct.Username);
|
||||||
|
sb.Append(",\"chars\":[");
|
||||||
|
|
||||||
|
bool first = true;
|
||||||
|
for (int i = 0; i < acct.Length; i++)
|
||||||
|
{
|
||||||
|
var m = acct[i];
|
||||||
|
if (m == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first) sb.Append(',');
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
sb.Append("{\"slot\":").Append(i);
|
||||||
|
sb.Append(",\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||||
|
sb.Append(",\"name\":");
|
||||||
|
BridgeJson.Escape(sb, m.Name ?? "");
|
||||||
|
sb.Append(",\"body\":").Append(m.Body.BodyID);
|
||||||
|
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
sb.Append(']');
|
||||||
|
|
||||||
|
return sb.End();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,45 @@
|
|||||||
param(
|
param(
|
||||||
[int] $Port = 7788,
|
[int] $Port = 7788,
|
||||||
[string] $Log = "$PSScriptRoot\sidecar_loop.log"
|
[string] $Log = "$PSScriptRoot\sc_robust.log"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
# Robust stub sidecar: survives port-in-use from a just-killed instance, and never
|
||||||
"[sidecar] listening on 127.0.0.1:$Port" | Out-File $Log -Encoding utf8
|
# dies on a transient error. Test scaffolding only.
|
||||||
|
|
||||||
|
function Say($msg) {
|
||||||
|
for ($i = 0; $i -lt 5; $i++) {
|
||||||
|
try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
|
||||||
|
catch { Start-Sleep -Milliseconds 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"" | Out-File -FilePath $Log -Encoding utf8
|
||||||
|
Say "[sidecar] starting on 127.0.0.1:$Port"
|
||||||
|
|
||||||
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
|
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
|
||||||
$listener.Start()
|
$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
|
||||||
|
|
||||||
|
# Wait for the port to become bindable if a prior instance is still lingering.
|
||||||
|
$bound = $false
|
||||||
|
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
|
||||||
|
try { $listener.Start(); $bound = $true }
|
||||||
|
catch { Say "[sidecar] bind retry: $($_.Exception.Message)"; Start-Sleep -Seconds 1 }
|
||||||
|
}
|
||||||
|
if (-not $bound) { Say "[sidecar] could not bind $Port; giving up"; exit 1 }
|
||||||
|
|
||||||
|
Say "[sidecar] listening"
|
||||||
|
|
||||||
while ($true) {
|
while ($true) {
|
||||||
try {
|
try {
|
||||||
$client = $listener.AcceptTcpClient()
|
$client = $listener.AcceptTcpClient()
|
||||||
"[sidecar] === shard connected ===" | Add-Content $Log
|
Say "[sidecar] === shard connected ==="
|
||||||
|
$reader = New-Object System.IO.StreamReader($client.GetStream())
|
||||||
$stream = $client.GetStream()
|
while ($null -ne ($line = $reader.ReadLine())) { Say "[sidecar] <- $line" }
|
||||||
$reader = New-Object System.IO.StreamReader($stream)
|
Say "[sidecar] === shard disconnected ==="
|
||||||
|
|
||||||
while ($null -ne ($line = $reader.ReadLine())) {
|
|
||||||
"[sidecar] <- $line" | Add-Content $Log
|
|
||||||
}
|
|
||||||
|
|
||||||
"[sidecar] === shard disconnected ===" | Add-Content $Log
|
|
||||||
$client.Close()
|
$client.Close()
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
"[sidecar] error: $_" | Add-Content $Log
|
Say "[sidecar] loop error: $($_.Exception.Message)"
|
||||||
Start-Sleep -Milliseconds 200
|
Start-Sleep -Milliseconds 300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
tools/stub_sidecar_request.ps1
Normal file
65
tools/stub_sidecar_request.ps1
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
param(
|
||||||
|
[int] $Port = 7788,
|
||||||
|
[string] $Log = "$PSScriptRoot\sc_request.log"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Say($msg) {
|
||||||
|
for ($i = 0; $i -lt 5; $i++) {
|
||||||
|
try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
|
||||||
|
catch { Start-Sleep -Milliseconds 100 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"" | Out-File -FilePath $Log -Encoding utf8
|
||||||
|
Say "[req] starting on 127.0.0.1:$Port"
|
||||||
|
|
||||||
|
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
|
||||||
|
$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
|
||||||
|
|
||||||
|
$bound = $false
|
||||||
|
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
|
||||||
|
try { $listener.Start(); $bound = $true }
|
||||||
|
catch { Start-Sleep -Seconds 1 }
|
||||||
|
}
|
||||||
|
if (-not $bound) { Say "[req] could not bind"; exit 1 }
|
||||||
|
|
||||||
|
Say "[req] listening"
|
||||||
|
$client = $listener.AcceptTcpClient()
|
||||||
|
Say "[req] === shard connected ==="
|
||||||
|
|
||||||
|
$stream = $client.GetStream()
|
||||||
|
$reader = New-Object System.IO.StreamReader($stream)
|
||||||
|
$writer = New-Object System.IO.StreamWriter($stream)
|
||||||
|
$writer.AutoFlush = $true
|
||||||
|
|
||||||
|
# Give the shard a beat to send its hello, then fire the requests.
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
$requests = @(
|
||||||
|
'{"kind":"account.roster","reqId":"r-roster","account":"whitlocktech"}',
|
||||||
|
'{"kind":"char.request","reqId":"r-darrow","account":"whitlocktech","slot":0}',
|
||||||
|
'{"kind":"vendor.snapshot","reqId":"r-vendor","account":"seed_000"}',
|
||||||
|
'{"kind":"char.request","reqId":"r-bad","account":"does_not_exist","slot":0}',
|
||||||
|
'{"kind":"char.request","reqId":"r-serial","serial":"0x24C"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($r in $requests) {
|
||||||
|
$writer.WriteLine($r)
|
||||||
|
Say "[req] -> $r"
|
||||||
|
Start-Sleep -Milliseconds 400
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read replies for a few seconds.
|
||||||
|
$deadline = (Get-Date).AddSeconds(8)
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if ($stream.DataAvailable) {
|
||||||
|
$line = $reader.ReadLine()
|
||||||
|
if ($null -ne $line) { Say "[req] <- $line" }
|
||||||
|
} else {
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Say "[req] done"
|
||||||
|
$client.Close()
|
||||||
|
$listener.Stop()
|
||||||
Reference in New Issue
Block a user