From 3fb4b7dc9fd3f52f34393bc1d9fea14655d51775 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 11:14:36 -0500 Subject: [PATCH] feat(bridge): emit world.ruleset, the shard's published ruleset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 3.0 §5 (docs/link/v3.md). One frame describing how this shard is actually configured — expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule — so the website's rules page cannot drift from the server. Modelled on BridgeBoot.EmitHello, not on the diff sweeps: the ruleset changes only when an operator edits a .cfg, so there is nothing to poll. It subscribes Connected_Core, so a sidecar that comes up second still learns the ruleset, and `[bridge reload` re-emits for an operator who just edited a file. The frame is built from an EXPLICIT ALLOWLIST of Config.Get calls. Config.Entries is never enumerated — that would sweep in every key on the server, secrets included — and Server.cfg, Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg, Compiler.cfg, Reports.cfg and Client.cfg are named as excluded both here and in a code comment. The one connection detail published is Bridge.PublicConnectAddress, blank by default, which an operator sets deliberately for this purpose. `rev` is FNV-1a over the body so an unchanged reconnect is a site-side no-op. String.GetHashCode() is deliberately not used: it is seeded per process, so it would change on every restart and defeat the diff. Verified by compiling the full ServUO Scripts tree (6,205 files, net48, EJ) with this overlay substituted for the deployed Bridge copy — clean. Co-Authored-By: Claude --- overlay/Config/Bridge.cfg | 19 + overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 6 +- overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 13 + .../Scripts/Custom/Bridge/BridgeRuleset.cs | 355 ++++++++++++++++++ 4 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeRuleset.cs diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index ef09957..50f60f5 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -48,6 +48,25 @@ PresenceSweepSeconds=30 # house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine. HousingSweepSeconds=300 +# Shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One world.ruleset frame — expansion, which +# systems are on, skill/stat caps, account and house limits, champion scroll rules — +# emitted on every sidecar connect (and on [bridge reload), so the website's rules page +# cannot drift from the server. Not a sweep: it changes only when you edit a .cfg. +# +# The frame is built from an explicit allowlist of keys in BridgeRuleset.cs. Server.cfg, +# Staff.cfg, Email.cfg, DataPath.cfg, Bridge.cfg, Compiler.cfg, Reports.cfg and Client.cfg +# are never read. +RulesetEnabled=true + +# The one connection detail the bridge will publish, e.g. play.myshard.com,2593. Blank +# (the default) omits it entirely. Server.cfg's Address/Listen/Port are NEVER published — +# if you want a connect string on the site, put it here deliberately. +PublicConnectAddress= + +# Include the save/restart schedule (AutoSave frequency, AutoRestart hour) in the frame. +# Turn off if you would rather not advertise a predictable restart window. +RulesetIncludeSchedule=true + # Shown to a player when they run [link. The website page where they enter the code. LinkUrl=https://yoursite/link diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 4af7e27..5708b6b 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -165,8 +165,11 @@ namespace Server.Custom.Bridge BridgeGovernance.Rearm(); BridgePresence.Rearm(); BridgeHousing.Rearm(); + // Not a sweep, so it has nothing to re-arm — but an operator who just edited a + // .cfg wants the change on the site now, not after a shard restart. + BridgeRuleset.Emit(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); - e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); + e.Mobile.SendMessage("Bridge: sweeps re-armed; ruleset re-emitted; endpoint changes take effect on reconnect."); break; case "ping": @@ -203,6 +206,7 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); + e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index 21907ce..9433ebe 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -36,6 +36,11 @@ namespace Server.Custom.Bridge public static int PresenceSweepSeconds { get; private set; } public static int HousingSweepSeconds { get; private set; } + // ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ---- + public static bool RulesetEnabled { get; private set; } + public static string PublicConnectAddress { get; private set; } + public static bool RulesetIncludeSchedule { get; private set; } + public static string LinkUrl { get; private set; } public static int TownCrierMaxLines { get; private set; } @@ -107,6 +112,14 @@ namespace Server.Custom.Bridge if (HousingSweepSeconds < 1) HousingSweepSeconds = 1; + // The ruleset frame is not a sweep — it is emitted once per sidecar connect (and on + // `[bridge reload`), so it has no interval. PublicConnectAddress is the ONE connection + // detail the bridge will publish, and only because an operator typed it here for that + // purpose; Server.cfg's Address/Port are never read (see BridgeRuleset's allowlist note). + RulesetEnabled = Config.Get("Bridge.RulesetEnabled", true); + PublicConnectAddress = Config.Get("Bridge.PublicConnectAddress", ""); + RulesetIncludeSchedule = Config.Get("Bridge.RulesetIncludeSchedule", true); + LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link"); TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6); diff --git a/overlay/Scripts/Custom/Bridge/BridgeRuleset.cs b/overlay/Scripts/Custom/Bridge/BridgeRuleset.cs new file mode 100644 index 0000000..76addc0 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeRuleset.cs @@ -0,0 +1,355 @@ +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"); + } + } +} -- 2.49.1