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"); + } + } +}