From 7e48d60a8b2e88d21566d3dc0af21a485ea16be2 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:34:14 -0500 Subject: [PATCH] Phase 4: character-profile request/response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Scripts/Custom/Bridge/BridgeProfile.cs | 245 ++++++++++++++++++ .../Scripts/Custom/Bridge/BridgeRequests.cs | 215 +++++++++++++++ tools/stub_sidecar.ps1 | 46 ++-- tools/stub_sidecar_request.ps1 | 65 +++++ 4 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeProfile.cs create mode 100644 overlay/Scripts/Custom/Bridge/BridgeRequests.cs create mode 100644 tools/stub_sidecar_request.ps1 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()