using System; using System.Text; using Server.Accounting; using Server.Engines.Points; 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 https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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 https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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(']'); WriteTitles(sb, m); WritePoints(sb, m); return sb.End(); } /// /// The titles a character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.3). `selected` is the index into /// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed /// display titles (may be absent). `reward` is the raw reward-title list — an entry may be /// a cliloc number (as a string) or a literal string; resolve clilocs website-side. /// private static void WriteTitles(StringBuilder sb, PlayerMobile m) { sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle); var fameKarma = m.FameKarmaTitle; if (!String.IsNullOrEmpty(fameKarma)) { sb.Append(",\"fameKarma\":"); BridgeJson.Escape(sb, fameKarma); } var skill = m.PaperdollSkillTitle; if (!String.IsNullOrEmpty(skill)) { sb.Append(",\"skill\":"); BridgeJson.Escape(sb, skill); } sb.Append(",\"reward\":["); var rewards = m.RewardTitles; if (rewards != null) { bool first = true; for (int i = 0; i < rewards.Count; i++) { var r = rewards[i]; if (r == null) continue; if (!first) sb.Append(','); first = false; BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture)); } } sb.Append("]}"); } /// /// The point/loyalty standings this character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7.3). Read-model /// enrichment on an existing kind, exactly like — there is no /// request kind for "one character's points", because the profile is already the place /// the website asks for everything about one character. /// /// Systems with no entry, or an entry at zero, are omitted: ten of the ~25 systems have /// AutoAdd = true and therefore hold a zero-point row for every character that has ever /// logged in, so emitting them all would be ~25 lines of noise on every sheet. /// /// **Never call PointsSystem.GetEntry / GetPoints here.** Both look benign and both /// MUTATE THE WORLD: `GetEntry(from, create: false)` still calls AddEntry when the system /// has AutoAdd (PointsSystem.cs:207), which appends a row to PlayerTable and fires /// OnPlayerAdded. A read model that used them would silently grow the points save file by /// up to ten rows every time anyone viewed a character sheet. Hence the manual scan. /// /// Cost: one early-exiting pass over each published system's PlayerTable. The AutoAdd /// tables are census-sized, so this is the dominant term in the profile — roughly 10 × n /// comparisons, against the ~0.069 ms/2.4 KB the rest of the profile measures at. That is /// acceptable because profiles are built on demand at human rates and never in a sweep; /// PointsProfileEnabled turns it off for a shard where it isn't. /// /// **Deliberately no `rank`.** Rank cannot early-exit — it must count every row that /// beats the player, in every system, every time — and the website can derive it from /// the points.board frame for anyone who is actually on a board. See PointsProfileRank. /// private static void WritePoints(StringBuilder sb, PlayerMobile m) { if (!BridgeConfig.PointsProfileEnabled) return; sb.Append(",\"points\":["); try { var systems = PointsSystem.Systems; if (systems != null) { bool first = true; for (int i = 0; i < systems.Count; i++) { var sys = systems[i]; if (sys == null || !sys.ShowOnLoyaltyGump) continue; var points = LookupPoints(sys, m); if (points <= 0) continue; if (!first) sb.Append(','); first = false; var name = sys.Name; sb.Append("{\"system\":\"").Append(sys.Loyalty).Append('"'); sb.Append(",\"nameString\":"); if (name == null || name.String == null) sb.Append("null"); else BridgeJson.Escape(sb, name.String); sb.Append(",\"nameNumber\":").Append(name == null ? 0 : name.Number); sb.Append(",\"points\":").Append(BridgePoints.Score(points)); sb.Append(",\"maxPoints\":").Append(BridgePoints.Cap(sys.MaxPoints)); // Off by default. The field is absent rather than null when disabled, so a // consumer can tell "this shard does not compute rank" from "unranked". if (BridgeConfig.PointsProfileRank) sb.Append(",\"rank\":").Append(RankOf(sys, points)); sb.Append('}'); } } } catch (Exception ex) { // A profile is worth more than its points block; never fail the sheet over one. Console.WriteLine("[Bridge] profile points threw: {0}", ex.Message); } sb.Append(']'); } /// /// This character's score in one system, or 0 if it has no entry. A hand-rolled scan /// rather than GetEntry/GetPoints for the mutation reason above; it stops at the match, /// which the rank computation could not. /// private static double LookupPoints(PointsSystem sys, PlayerMobile m) { var table = sys.PlayerTable; if (table == null) return 0; for (int i = 0; i < table.Count; i++) { var entry = table[i]; if (entry != null && entry.Player == m) return entry.Points; } return 0; } /// /// 1-based standing in one system: how many live characters hold strictly more points, /// plus one. Ties share a rank, which is what a player expects to see. /// /// Only reachable with PointsProfileRank=true, and off by default for the reason stated /// in : unlike the points lookup, this visits every row of the /// table every time, so it turns a bounded early-exiting scan into a guaranteed full one /// per published system per profile. /// private static int RankOf(PointsSystem sys, double points) { var table = sys.PlayerTable; if (table == null) return 1; var better = 0; for (int i = 0; i < table.Count; i++) { var entry = table[i]; if (entry == null || entry.Player == null || entry.Player.Deleted) continue; if (entry.Points > points) better++; } return better + 1; } 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(); } } }