feat(bridge): emit world.ruleset, the shard's published ruleset #3

Merged
whitlocktech merged 1 commits from feat/bridge-ruleset into edge 2026-07-28 20:37:07 +00:00
4 changed files with 392 additions and 1 deletions

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -0,0 +1,355 @@
using System;
using System.Text;
using Server.Engines.CityLoyalty;
using Server.Engines.VvV;
using Server.Multis;
namespace Server.Custom.Bridge
{
/// <summary>
/// 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 <see cref="BridgeBoot.EmitHello"/>, 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.
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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('}');
}
/// <summary>
/// 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).
/// </summary>
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('}');
}
/// <summary>
/// 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.
/// </summary>
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('}');
}
/// <summary>The Felucca risk-vs-reward numbers — the reason players choose a facet.</summary>
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('}');
}
/// <summary>
/// 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.
/// </summary>
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('}');
}
/// <summary>
/// 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.
/// </summary>
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('}');
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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");
}
}
}