diff --git a/README.md b/README.md
index c36fc6d..92886e7 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** |
-| 4 — request/response | not started |
+| 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** |
| 5 — `[link` account linking | not started |
| 6 — town-crier inbound | not started |
| 7 — `PlayerVendorSale` core event | not started |
@@ -67,15 +67,21 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. |
+| `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. |
+| `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. |
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.
## Testing
-`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive.
+`tools/stub_sidecar.ps1` is a loopback listener that logs every line the shard sends. Run it, boot the shard, watch `server.hello` arrive. It survives a just-killed instance (SO_REUSEADDR) and won't die on a transient error.
```powershell
.\tools\stub_sidecar.ps1 -Port 7788 -Log .\sidecar.log
```
+`tools/stub_sidecar_request.ps1` additionally *sends* inbound requests (`char.request`, `account.roster`, `vendor.snapshot`, plus an error case) right after the shard connects, and logs the replies — the harness used to validate Phase 4.
+
+Note: the throwaway PowerShell sidecars are fragile — they get reaped and contend on their log file. The real Rust sidecar replaces them; don't read their flakiness as a shard problem. The shard buffers non-perishable events through any outage and reconnects on its own (observed reconnecting 5× unattended in one session).
+
`tools/scaffolding/` holds the world seeder and the performance probe. Neither is deployed — `deploy.ps1` only copies `overlay/`. They produced the budget in `docs/PLAN.md` §1. See `tools/scaffolding/README.md`.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 76fa16d..5c2dc2d 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -306,7 +306,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13.
-4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side.
+4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests.
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries.
7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed.
@@ -348,6 +348,27 @@ Two defects were found this way and fixed:
---
+## 14. Phase 4 acceptance
+
+`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence.
+
+Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread:
+
+- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline.
+- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed.
+- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree.
+- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each.
+- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`.
+
+Two things the real character surfaced that the seeded dummies could not:
+
+- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully.
+- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking.
+
+Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete.
+
+---
+
## 13. Phase 3 acceptance
`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters.
diff --git a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
new file mode 100644
index 0000000..7e12197
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
@@ -0,0 +1,245 @@
+using System;
+using System.Text;
+
+using Server.Accounting;
+using Server.Items;
+using Server.Mobiles;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// 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.
+ ///
+ 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 ----
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeRequests.cs b/overlay/Scripts/Custom/Bridge/BridgeRequests.cs
new file mode 100644
index 0000000..e7f277d
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeRequests.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Accounting;
+using Server.Mobiles;
+using Server.Multis;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// 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.
+ ///
+ 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 ----
+
+ ///
+ /// 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.
+ ///
+ private static void OnCharRequest(Dictionary 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 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 ----
+
+ ///
+ /// 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.
+ ///
+ private static void OnVendorSnapshotRequest(Dictionary 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;
+ }
+ }
+ }
+}
diff --git a/tools/stub_sidecar.ps1 b/tools/stub_sidecar.ps1
index 2c65604..8f367f8 100644
--- a/tools/stub_sidecar.ps1
+++ b/tools/stub_sidecar.ps1
@@ -1,31 +1,45 @@
param(
[int] $Port = 7788,
- [string] $Log = "$PSScriptRoot\sidecar_loop.log"
+ [string] $Log = "$PSScriptRoot\sc_robust.log"
)
-$ErrorActionPreference = 'Stop'
-"[sidecar] listening on 127.0.0.1:$Port" | Out-File $Log -Encoding utf8
+# Robust stub sidecar: survives port-in-use from a just-killed instance, and never
+# 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.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) {
try {
$client = $listener.AcceptTcpClient()
- "[sidecar] === shard connected ===" | Add-Content $Log
-
- $stream = $client.GetStream()
- $reader = New-Object System.IO.StreamReader($stream)
-
- while ($null -ne ($line = $reader.ReadLine())) {
- "[sidecar] <- $line" | Add-Content $Log
- }
-
- "[sidecar] === shard disconnected ===" | Add-Content $Log
+ Say "[sidecar] === shard connected ==="
+ $reader = New-Object System.IO.StreamReader($client.GetStream())
+ while ($null -ne ($line = $reader.ReadLine())) { Say "[sidecar] <- $line" }
+ Say "[sidecar] === shard disconnected ==="
$client.Close()
}
catch {
- "[sidecar] error: $_" | Add-Content $Log
- Start-Sleep -Milliseconds 200
+ Say "[sidecar] loop error: $($_.Exception.Message)"
+ Start-Sleep -Milliseconds 300
}
}
diff --git a/tools/stub_sidecar_request.ps1 b/tools/stub_sidecar_request.ps1
new file mode 100644
index 0000000..c0d18a6
--- /dev/null
+++ b/tools/stub_sidecar_request.ps1
@@ -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()