using System; using System.Text; using Server.Engines.CityLoyalty; using Server.Engines.VvV; using Server.Multis; namespace Server.Custom.Bridge { /// /// The shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One `world.ruleset` frame describing how /// this shard is actually configured: expansion, which systems are on, skill/stat caps, house /// and account limits, champion scroll rules, and the save/restart schedule. It is what turns /// the website's "Rules" page from hand-maintained prose into something that cannot drift from /// the server. /// /// Modelled on , NOT on the diff sweeps: the ruleset changes /// only when an operator edits Config/*.cfg and restarts (or runs `[bridge reload`), so there is /// nothing to poll. It subscribes Connected_Core so a sidecar that comes up second still learns /// the ruleset, exactly as server.hello does. /// /// **The one hard rule: this is an explicit allowlist of Config.Get calls.** Never enumerate /// Config.Entries (Server/Config.cs) — that would sweep in every key on the server, secrets /// included. Files deliberately never read here, in addition to anything not named below: /// /// Server.cfg — Address / Listen / Port. Only Bridge.PublicConnectAddress is published, /// and only because an operator typed it there for exactly this purpose. /// Staff.cfg — staff account names. /// Email.cfg — SMTP credentials. /// DataPath.cfg — filesystem layout. /// Bridge.cfg — the sidecar host/port and our own caps. /// Compiler.cfg — build flags. /// Reports.cfg — report upload credentials. /// Client.cfg — client-version enforcement (not player-facing rules). /// /// `rev` is an FNV-1a hash of the emitted body, so a reconnect that carries an unchanged ruleset /// is a no-op site-side. String.GetHashCode() is deliberately NOT used: it is randomized per /// process on modern .NET, so it would change on every shard restart and defeat the whole point. /// public static class BridgeRuleset { private static long _emitted; private static string _rev = ""; private static int _bytes; public static void Initialize() { if (!BridgeConfig.Enabled) return; BridgeLink.Connected_Core += Emit; } public static string Status() { return String.Format("ruleset(enabled={0} emitted={1} rev={2} bytes={3})", BridgeConfig.RulesetEnabled, _emitted, _rev.Length == 0 ? "-" : _rev, _bytes); } /// /// Core thread. Builds and queues one `world.ruleset` frame. Called on every sidecar /// connect and by `[bridge reload` (an operator who just edited a .cfg wants to see the /// change on the site without restarting the shard). /// public static void Emit() { if (!BridgeConfig.RulesetEnabled) return; try { var body = BuildBody(); _rev = Fnv1a(body); _bytes = body.Length; _emitted++; // rev goes first so a reader can short-circuit on an unchanged frame before parsing // the rest of it. BridgeLink.Emit(BridgeJson.Begin("world.ruleset") .Str("rev", _rev) .Append(body) .End()); } catch (Exception ex) { Console.WriteLine("[Bridge] ruleset emit threw: {0}", ex.Message); } } /// /// The allowlist. Every block is optional and omitted when its system is off, so a shard /// that does not run (say) VvV publishes no `vvv` block rather than a block of zeroes. /// private static string BuildBody() { var sb = new StringBuilder(2048); sb.Str("shard", Server.Misc.ServerList.ServerName); sb.Str("expansion", Core.Expansion.ToString()); // The ONLY thing published from a connection-address setting, and only because the // operator put it in Bridge.cfg specifically to be shown. Server.cfg is never read. var connect = BridgeConfig.PublicConnectAddress; if (!String.IsNullOrEmpty(connect)) sb.Str("connect", connect); WriteSystems(sb); WriteCaps(sb); sb.Append(",\"housing\":{\"accountHouseLimit\":") .Append(BaseHouse.AccountHouseLimit).Append('}'); WriteAccounts(sb); WriteVetRewards(sb); WriteLoot(sb); WriteVendors(sb); WriteChampions(sb); WriteTreasureMaps(sb); WriteVvV(sb); WriteStore(sb); if (BridgeConfig.RulesetIncludeSchedule) WriteSchedule(sb); return sb.ToString(); } /// /// Which optional systems this shard runs. Read from each system's own static rather than /// re-parsing its .cfg, so a system that derives its state (Factions is on exactly when VvV /// is off — Services/Factions/Core/Faction.cs) is reported the way the server actually sees /// it. This block subsumes the `world.systems` capability frame PROTOCOL_2.md §10.4 /// sketched but never implemented. /// private static void WriteSystems(StringBuilder sb) { sb.Append(",\"systems\":{"); sb.Append("\"cityLoyalty\":").Append(Json(CityLoyaltySystem.Enabled)); sb.Append(",\"vvv\":").Append(Json(ViceVsVirtueSystem.Enabled)); sb.Append(",\"factions\":").Append(Json(Server.Factions.Settings.Enabled)); sb.Append(",\"siege\":").Append(Json(Siege.SiegeShard)); sb.Append(",\"chat\":").Append(Json(Config.Get("Chat.Enabled", true))); sb.Append(",\"store\":").Append(Json(Config.Get("Store.Enabled", true))); sb.Append(",\"dailyRares\":").Append(Json(Config.Get("DailyRares.Enabled", true))); sb.Append(",\"honesty\":").Append(Json(Config.Get("Honesty.Enabled", true))); sb.Append(",\"shadowguard\":").Append(Json(Core.TOL)); sb.Append(",\"treasureMaps\":").Append(Json(Config.Get("TreasureMaps.Enabled", true))); sb.Append(",\"vetRewards\":").Append(Json(Config.Get("VetRewards.Enabled", true))); sb.Append(",\"testCenter\":").Append(Json(Config.Get("TestCenter.Enabled", false))); sb.Append('}'); } /// /// Skill and stat caps — the single most-asked "what are the rules here?" question, and the /// one most often wrong on a hand-written page. SkillCap is in tenths (1000 = 100.0). /// private static void WriteCaps(StringBuilder sb) { sb.Append(",\"caps\":{"); sb.Append("\"skill\":").Append(Config.Get("PlayerCaps.SkillCap", 1000)); sb.Append(",\"totalSkill\":").Append(Config.Get("PlayerCaps.TotalSkillCap", 7000)); sb.Append(",\"stat\":").Append(Config.Get("PlayerCaps.TotalStatCap", 225)); sb.Append(",\"str\":").Append(Config.Get("PlayerCaps.StrCap", 125)); sb.Append(",\"dex\":").Append(Config.Get("PlayerCaps.DexCap", 125)); sb.Append(",\"int\":").Append(Config.Get("PlayerCaps.IntCap", 125)); sb.Append(",\"strMax\":").Append(Config.Get("PlayerCaps.StrMaxCap", 150)); sb.Append(",\"dexMax\":").Append(Config.Get("PlayerCaps.DexMaxCap", 150)); sb.Append(",\"intMax\":").Append(Config.Get("PlayerCaps.IntMaxCap", 150)); sb.Append('}'); } /// /// Account limits. `autoCreate` is the in-game first-login auto-create switch, which pairs /// with the bridge's own SignupMode (BridgeConfig.WarnOnSignupMismatch) — publishing it /// lets the site's signup page tell a visitor the truth about how to get an account. /// Character slots come from Siege.cfg, which is where ServUO keeps them regardless of /// whether the shard is actually Siege. /// private static void WriteAccounts(StringBuilder sb) { sb.Append(",\"accounts\":{"); sb.Append("\"perIp\":").Append(Config.Get("Accounts.AccountsPerIp", 1)); sb.Append(",\"charSlots\":").Append(Siege.CharacterSlots); sb.Append(",\"autoCreate\":").Append(Json(Config.Get("Accounts.AutoCreateAccounts", true))); sb.Append('}'); } private static void WriteVetRewards(StringBuilder sb) { var enabled = Config.Get("VetRewards.Enabled", true); sb.Append(",\"vetRewards\":{\"enabled\":").Append(Json(enabled)); if (enabled) { var interval = Config.Get("VetRewards.RewardInterval", TimeSpan.FromDays(30.0)); sb.Append(",\"rewardIntervalDays\":").Append((int)interval.TotalDays); } sb.Append('}'); } /// The Felucca risk-vs-reward numbers — the reason players choose a facet. private static void WriteLoot(StringBuilder sb) { sb.Append(",\"loot\":{"); sb.Append("\"feluccaLuckBonus\":").Append(Config.Get("Loot.FeluccaLuckBonus", 0)); sb.Append(",\"feluccaBudgetBonus\":").Append(Config.Get("Loot.FeluccaBudgetBonus", 0)); sb.Append(",\"feluccaMaxProps\":").Append(Config.Get("Loot.MaxProps", 5)); sb.Append('}'); } private static void WriteVendors(StringBuilder sb) { sb.Append(",\"vendors\":{"); sb.Append("\"restockDelayMinutes\":").Append(Config.Get("Vendors.RestockDelay", 60)); sb.Append(",\"maxSell\":").Append(Config.Get("Vendors.MaxSell", 500)); sb.Append(",\"economyStockAmount\":").Append(Config.Get("Vendors.EconomyStockAmount", 500)); sb.Append('}'); } /// /// Champion spawn rewards. `rankThresholds` is the red-skull count at which each rank is /// reached, which is what a player actually wants to know before committing to a spawn. /// private static void WriteChampions(StringBuilder sb) { if (!Config.Get("Champions.Enabled", true)) return; sb.Append(",\"champions\":{"); sb.Append("\"powerScrolls\":").Append(Config.Get("Champions.PowerScrolls", 6)); sb.Append(",\"statScrolls\":").Append(Config.Get("Champions.StatScrolls", 16)); sb.Append(",\"scrollChance\":").Append(Json(Config.Get("Champions.ScrollChance", 0.1))); sb.Append(",\"transcendenceChance\":") .Append(Json(Config.Get("Champions.TranscendenceChance", 50.0))); sb.Append(",\"rankThresholds\":[") .Append(Config.Get("Champions.Rank2RedSkulls", 5)).Append(',') .Append(Config.Get("Champions.Rank3RedSkulls", 10)).Append(',') .Append(Config.Get("Champions.Rank4RedSkulls", 13)) .Append(']'); sb.Append('}'); } private static void WriteTreasureMaps(StringBuilder sb) { var enabled = Config.Get("TreasureMaps.Enabled", true); sb.Append(",\"treasureMaps\":{\"enabled\":").Append(Json(enabled)); if (enabled) { sb.Append(",\"lootChance\":").Append(Json(Config.Get("TreasureMaps.LootChance", 0.01))); sb.Append(",\"resetDays\":").Append(Json(Config.Get("TreasureMaps.ResetTime", 30.0))); } sb.Append('}'); } private static void WriteVvV(StringBuilder sb) { if (!ViceVsVirtueSystem.Enabled) return; sb.Append(",\"vvv\":{"); sb.Append("\"enabled\":true"); sb.Append(",\"startSilver\":").Append(ViceVsVirtueSystem.StartSilver); sb.Append(",\"enhancedRules\":").Append(Json(ViceVsVirtueSystem.EnhancedRules)); sb.Append('}'); } /// /// The Ultima Store. Only `enabled` and the currency's display name — never the store's /// price table or any payment configuration, neither of which lives in Config anyway. /// private static void WriteStore(StringBuilder sb) { var enabled = Config.Get("Store.Enabled", true); sb.Append(",\"store\":{\"enabled\":").Append(Json(enabled)); if (enabled) sb.Str("currencyName", Config.Get("Store.CurrencyName", "Sovereigns")); sb.Append('}'); } /// /// Save and restart schedule — "when does the shard hiccup?", the other question a live /// status page is asked. Off behind RulesetIncludeSchedule for an operator who would rather /// not advertise a predictable restart window. /// private static void WriteSchedule(StringBuilder sb) { sb.Append(",\"schedule\":{"); var saves = Config.Get("AutoSave.Enabled", true); sb.Append("\"autoSaveEnabled\":").Append(Json(saves)); if (saves) { var freq = Config.Get("AutoSave.Frequency", TimeSpan.FromMinutes(5.0)); sb.Append(",\"autoSaveFrequencyMinutes\":").Append((int)freq.TotalMinutes); } var restart = Config.Get("AutoRestart.Enabled", false); sb.Append(",\"autoRestartEnabled\":").Append(Json(restart)); if (restart) { sb.Append(",\"autoRestartHour\":").Append(Config.Get("AutoRestart.Hour", 12)); sb.Append(",\"autoRestartMinute\":").Append(Config.Get("AutoRestart.Minute", 0)); sb.Append(",\"autoRestartFrequencyHours\":").Append(Config.Get("AutoRestart.Frequency", 24)); } sb.Append('}'); } // ---- helpers ---- private static string Json(bool value) { return value ? "true" : "false"; } private static string Json(double value) { return value.ToString("R", System.Globalization.CultureInfo.InvariantCulture); } /// /// FNV-1a over the UTF-16 code units of the body, as 8 lowercase hex digits. Any stable /// hash would do; what matters is that it is stable ACROSS PROCESSES, which /// String.GetHashCode() is not (it is seeded randomly per process), so using that would /// produce a different rev after every restart and make the whole diff pointless. /// private static string Fnv1a(string s) { const uint offset = 2166136261; const uint prime = 16777619; uint hash = offset; for (int i = 0; i < s.Length; i++) { char c = s[i]; hash = (hash ^ (byte)(c & 0xFF)) * prime; hash = (hash ^ (byte)(c >> 8)) * prime; } return hash.ToString("x8"); } } }