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