using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Threading.Tasks; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: AssemblyCompany("Catosaur")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyDescription("Admin-controlled server-wide boss and crafting progression for Valheim.")] [assembly: AssemblyFileVersion("0.1.1.0")] [assembly: AssemblyInformationalVersion("0.1.1+ff36e656ddd2abc4421a653bfe3fc76f2ce6e498")] [assembly: AssemblyProduct("ProgressionGater")] [assembly: AssemblyTitle("ProgressionGater")] [assembly: AssemblyVersion("0.1.1.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace ProgressionGater { [HarmonyPatch] internal static class AdminCommands { private const string CommandRpc = "ProgressionGater.AdminCommand.v1"; private const string ReplyRpc = "ProgressionGater.AdminReply.v1"; private static bool _registered; private static Terminal _pendingTerminal; [HarmonyPatch(typeof(Terminal), "InitTerminal")] [HarmonyPostfix] private static void RegisterCommands() { if (!_registered) { _registered = true; Add("pg_unlock", " - allow a boss to be summoned", "unlock"); Add("pg_lock", " - prevent a boss from being summoned", "lock"); Add("pg_status", "show server-wide progression", "status"); Add("pg_defeat", " - administratively mark a boss defeated", "defeat"); Add("pg_undefeat", " - remove a recorded defeat", "undefeat"); Add("pg_reset", "confirm - clear all Progression Gater state", "reset"); Add("pg_webhook_test", "send a test Discord webhook", "webhook_test"); } } private static void Add(string name, string description, string command) { //IL_0016: Unknown result type (might be due to invalid IL or missing references) //IL_0029: Expected O, but got Unknown //IL_0024: Unknown result type (might be due to invalid IL or missing references) new ConsoleCommand(name, description, (ConsoleEvent)delegate(ConsoleEventArgs args) { Send(args, command); }, true, false, false, false, false, (ConsoleOptionsFetcher)null, false, false, false); } internal static void RegisterRpcHandlers(ZRoutedRpc rpc) { rpc.Register("ProgressionGater.AdminCommand.v1", (Action)HandleServerCommand); rpc.Register("ProgressionGater.AdminReply.v1", (Action)delegate(long sender, ZPackage package) { if (NetworkManager.IsFromServer(sender)) { HandleClientReply(package); } }); } private static void Send(ConsoleEventArgs args, string command) { //IL_00bf: Unknown result type (might be due to invalid IL or missing references) //IL_00c5: Expected O, but got Unknown string[] array = args.Args.Skip(1).ToArray(); if ((Object)(object)ZNet.instance == (Object)null) { args.Context.AddString("Not connected."); return; } if (ZNet.instance.IsServer()) { Player localPlayer = Player.m_localPlayer; string actor = ((localPlayer != null) ? localPlayer.GetPlayerName() : null) ?? "Server console"; { foreach (string item in Run(command, array, isAdmin: true, actor)) { args.Context.AddString(item); } return; } } ZNetPeer serverPeer = ZNet.instance.GetServerPeer(); if (serverPeer == null || ZRoutedRpc.instance == null) { args.Context.AddString("Server connection is not ready."); return; } ZPackage val = new ZPackage(); val.Write(command); val.Write(array.Length); string[] array2 = array; foreach (string text in array2) { val.Write(text); } _pendingTerminal = args.Context; ZRoutedRpc.instance.InvokeRoutedRPC(serverPeer.m_uid, "ProgressionGater.AdminCommand.v1", new object[1] { val }); } private static void HandleServerCommand(long sender, ZPackage package) { //IL_00bb: Unknown result type (might be due to invalid IL or missing references) //IL_00c2: Expected O, but got Unknown if (!ProgressionService.IsServer) { return; } ZNetPeer peer = ZNet.instance.GetPeer(sender); bool isAdmin = NetworkManager.IsAdmin(peer); string command; string[] array; try { command = package.ReadString(); int num = package.ReadInt(); if (num < 0 || num > 32) { throw new InvalidOperationException("Invalid argument count."); } array = new string[num]; for (int i = 0; i < num; i++) { array[i] = package.ReadString(); } } catch (Exception ex) { Plugin.Log.LogWarning((object)$"Rejected malformed admin command from {sender}: {ex.Message}"); return; } string actor = peer?.m_playerName ?? $"peer {sender}"; List list = Run(command, array, isAdmin, actor); ZPackage val = new ZPackage(); val.Write(list.Count); foreach (string item in list) { val.Write(item ?? ""); } ZRoutedRpc.instance.InvokeRoutedRPC(sender, "ProgressionGater.AdminReply.v1", new object[1] { val }); } private static void HandleClientReply(ZPackage package) { try { int num = package.ReadInt(); if (num < 0 || num > 256) { throw new InvalidOperationException("Invalid reply count."); } for (int i = 0; i < num; i++) { string text = package.ReadString(); if ((Object)(object)_pendingTerminal != (Object)null) { _pendingTerminal.AddString(text); } else { Plugin.Log.LogInfo((object)text); } } } catch (Exception ex) { Plugin.Log.LogWarning((object)("Invalid admin-command reply: " + ex.Message)); } finally { _pendingTerminal = null; } } private static List Run(string command, string[] args, bool isAdmin, string actor) { if (!isAdmin) { return new List { "Admin only." }; } return command switch { "unlock" => Unlock(args, actor), "lock" => Lock(args), "status" => Status(), "defeat" => Defeat(args), "undefeat" => Undefeat(args), "reset" => Reset(args), "webhook_test" => TestWebhook(actor), _ => new List { "Unknown command '" + command + "'." }, }; } private static List Unlock(string[] args, string actor) { if (args.Length < 1) { return new List { "Usage: pg_unlock " }; } BossDefinition bossDefinition = (string.Equals(args[0], "next", StringComparison.OrdinalIgnoreCase) ? ProgressionService.NextBoss() : ProgressionService.Rules.Resolve(args[0])); if (bossDefinition == null) { return new List { "Unknown boss '" + args[0] + "'." }; } if (!ProgressionService.Unlock(bossDefinition)) { return new List { bossDefinition.DisplayName + " is already summon-unlocked.", ProgressionService.Status(bossDefinition) }; } Plugin.Log.LogInfo((object)(actor + " unlocked boss summon: " + bossDefinition.DisplayName)); DiscordWebhook.PostBossUnlocked(bossDefinition, actor); return new List { bossDefinition.DisplayName + " may now be summoned.", "Its gated recipes remain locked until it is defeated." }; } private static List Lock(string[] args) { List error; BossDefinition bossDefinition = ResolveArgument(args, "Usage: pg_lock ", out error); if (bossDefinition == null) { return error; } if (!ProgressionService.Lock(bossDefinition)) { return new List { bossDefinition.DisplayName + " is already summon-locked." }; } return new List { bossDefinition.DisplayName + " can no longer be summoned. Its recorded defeat was not changed." }; } private static List Defeat(string[] args) { List error; BossDefinition bossDefinition = ResolveArgument(args, "Usage: pg_defeat ", out error); if (bossDefinition == null) { return error; } if (!ProgressionService.MarkDefeated(bossDefinition)) { return new List { bossDefinition.DisplayName + " is already recorded as defeated." }; } Plugin.Log.LogInfo((object)("Boss administratively marked defeated: " + bossDefinition.DisplayName)); return new List { bossDefinition.DisplayName + " marked defeated. No kill webhook was posted." }; } private static List Undefeat(string[] args) { List error; BossDefinition bossDefinition = ResolveArgument(args, "Usage: pg_undefeat ", out error); if (bossDefinition == null) { return error; } if (!ProgressionService.UnmarkDefeated(bossDefinition)) { return new List { bossDefinition.DisplayName + " was not recorded as defeated." }; } return new List { "Removed the recorded defeat for " + bossDefinition.DisplayName + "." }; } private static List Reset(string[] args) { if (args.Length != 1 || !string.Equals(args[0], "confirm", StringComparison.OrdinalIgnoreCase)) { return new List { "This clears all summon unlocks and defeats. Run: pg_reset confirm" }; } ProgressionService.Reset(); Plugin.Log.LogWarning((object)"All Progression Gater state was reset by an administrator."); return new List { "All Progression Gater state has been cleared." }; } private static List TestWebhook(string actor) { if (string.IsNullOrWhiteSpace(ModConfig.WebhookUrl.Value)) { return new List { "WebhookUrl is empty in the server config." }; } DiscordWebhook.PostTest(actor); return new List { "Webhook test queued. Check Discord and the BepInEx log." }; } private static List Status() { List list = new List(); list.Add("=== Progression Gater (" + (ProgressionService.Enabled ? "enabled" : "disabled") + ") ==="); list.Add("Boss summons: " + (ProgressionService.GateBossSummons ? "gated" : "open") + "; crafting: " + (ProgressionService.GateCrafting ? "gated" : "open")); list.Add("Discord webhook: " + (string.IsNullOrWhiteSpace(ModConfig.WebhookUrl.Value) ? "not configured" : "configured")); list.AddRange(ProgressionService.Rules.Bosses.Select((BossDefinition boss) => " " + ProgressionService.Status(boss))); return list; } private static BossDefinition ResolveArgument(string[] args, string usage, out List error) { if (args.Length < 1) { error = new List { usage }; return null; } BossDefinition bossDefinition = ProgressionService.Rules.Resolve(args[0]); error = ((bossDefinition == null) ? new List { "Unknown boss '" + args[0] + "'." } : null); return bossDefinition; } } internal sealed class BossDefinition { internal string Id; internal string DisplayName; internal string PrefabName; internal string GlobalKey; internal string[] Aliases = Array.Empty(); internal readonly HashSet Keywords = new HashSet(StringComparer.OrdinalIgnoreCase); internal readonly HashSet ExactPrefabs = new HashSet(StringComparer.OrdinalIgnoreCase); } internal static class DiscordWebhook { private static readonly HttpClient Client = new HttpClient { Timeout = TimeSpan.FromSeconds(10.0) }; internal static void PostBossUnlocked(BossDefinition boss, string admin) { if (ProgressionService.IsServer && ModConfig.WebhookPostUnlocks.Value) { string text = ModConfig.UnlockMessage.Value ?? ""; Post(Expand(text, boss, admin), text.IndexOf("{server}", StringComparison.OrdinalIgnoreCase) < 0); } } internal static void PostBossDefeated(BossDefinition boss) { if (ProgressionService.IsServer && ModConfig.WebhookPostDefeats.Value) { string text = ModConfig.DefeatMessage.Value ?? ""; Post(Expand(text, boss, ""), text.IndexOf("{server}", StringComparison.OrdinalIgnoreCase) < 0); } } internal static void PostTest(string admin) { if (ProgressionService.IsServer) { Post("✅ Progression Gater webhook test requested by **" + SafeMentionText(admin ?? "Server admin") + "**.", prefixServer: true); } } private static string Expand(string template, BossDefinition boss, string admin) { string value = ModConfig.WebhookServerLabel.Value ?? ""; return (template ?? "").Replace("{boss}", SafeMentionText(boss?.DisplayName ?? "Unknown boss")).Replace("{bossId}", SafeMentionText(boss?.Id ?? "unknown")).Replace("{admin}", SafeMentionText(admin ?? "Server admin")) .Replace("{server}", SafeMentionText(value)); } private static void Post(string message, bool prefixServer) { string text = (ModConfig.WebhookUrl.Value ?? "").Trim(); if (text.Length != 0 && !string.IsNullOrWhiteSpace(message) && TryValidateUrl(text, out var url)) { string text2 = (ModConfig.WebhookServerLabel.Value ?? "").Trim(); if (prefixServer && text2.Length > 0) { message = "**[" + SafeMentionText(text2) + "]** " + message; } if (message.Length > 1900) { message = message.Substring(0, 1900); } PostAsync(url, message); } } private static async Task PostAsync(Uri url, string message) { try { string text = "{\"username\":\"Progression Gater\",\"allowed_mentions\":{\"parse\":[]},\"content\":\"" + JsonEscape(message) + "\"}"; StringContent content = new StringContent(text, Encoding.UTF8, "application/json"); try { HttpResponseMessage val = await Client.PostAsync(url, (HttpContent)(object)content).ConfigureAwait(continueOnCapturedContext: false); try { if (!val.IsSuccessStatusCode) { Plugin.Log.LogWarning((object)$"Discord webhook returned HTTP {(int)val.StatusCode} ({val.ReasonPhrase})."); } } finally { ((IDisposable)val)?.Dispose(); } } finally { ((IDisposable)content)?.Dispose(); } } catch (Exception ex) { Plugin.Log.LogWarning((object)("Discord webhook post failed: " + ex.Message)); } } private static bool TryValidateUrl(string raw, out Uri url) { url = null; if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri result) || result.Scheme != Uri.UriSchemeHttps) { Plugin.Log.LogWarning((object)"Discord WebhookUrl must be an absolute HTTPS URL."); return false; } if (!ModConfig.AllowNonDiscordWebhookHosts.Value) { string text = result.Host.ToLowerInvariant(); if (!(text == "discord.com") && !text.EndsWith(".discord.com", StringComparison.Ordinal) && !(text == "discordapp.com") && !text.EndsWith(".discordapp.com", StringComparison.Ordinal)) { Plugin.Log.LogWarning((object)"WebhookUrl was not posted because its host is not Discord. Enable AllowNonDiscordWebhookHosts only for a trusted compatible endpoint."); return false; } } url = result; return true; } private static string SafeMentionText(string value) { return (value ?? "").Replace("@", "@\u200b"); } private static string JsonEscape(string value) { StringBuilder stringBuilder = new StringBuilder(value.Length + 16); foreach (char c in value) { switch (c) { case '\\': stringBuilder.Append("\\\\"); continue; case '"': stringBuilder.Append("\\\""); continue; case '\b': stringBuilder.Append("\\b"); continue; case '\f': stringBuilder.Append("\\f"); continue; case '\n': stringBuilder.Append("\\n"); continue; case '\r': stringBuilder.Append("\\r"); continue; case '\t': stringBuilder.Append("\\t"); continue; } if (c < ' ') { StringBuilder stringBuilder2 = stringBuilder.Append("\\u"); int num = c; stringBuilder2.Append(num.ToString("x4")); } else { stringBuilder.Append(c); } } return stringBuilder.ToString(); } } internal static class ModConfig { internal const string DefaultBossDefinitions = "eikthyr|Eikthyr|Eikthyr|defeated_eikthyr|deer;elder|The Elder|gd_king|defeated_gdking|gdking;bonemass|Bonemass|Bonemass|defeated_bonemass|;moder|Moder|Dragon|defeated_dragon|;yagluth|Yagluth|GoblinKing|defeated_goblinking|goblinking,defeated_gdking_varguts;queen|The Queen|SeekerQueen|defeated_queen|seekerqueen;fader|Fader|Fader|defeated_fader|"; internal const string DefaultKeywordRules = "eikthyr=Bronze;elder=Iron;bonemass=Silver,Obsidian;moder=BlackMetal;yagluth=BlackCore,BlackMarble,Carapace,Mandible,Softtissue,YggdrasilWood,Eitr,ScaleHide,RoyalJelly,Sap,JotunPuffs,Magecap;queen=Flametal,Grausten,AskHide,Asksvin,Morgen,CharredBone,CelestialFeather,CeramicPlate,BellFragment,Blackwood,Proustite,Sulfur,Fiddlehead,Vineberry,SmokePuff,Volture,Bonemaw"; internal static ConfigEntry Enabled; internal static ConfigEntry GateBossSummons; internal static ConfigEntry GateCrafting; internal static ConfigEntry MatchRecipeIngredients; internal static ConfigEntry AdminBypass; internal static ConfigEntry BossDefinitions; internal static ConfigEntry KeywordRules; internal static ConfigEntry ExactPrefabRules; internal static ConfigEntry SummonUnlocks; internal static ConfigEntry DefeatedBosses; internal static ConfigEntry WebhookUrl; internal static ConfigEntry WebhookServerLabel; internal static ConfigEntry WebhookPostUnlocks; internal static ConfigEntry WebhookPostDefeats; internal static ConfigEntry AllowNonDiscordWebhookHosts; internal static ConfigEntry UnlockMessage; internal static ConfigEntry DefeatMessage; internal static void Bind(ConfigFile config, Action changed) { Enabled = config.Bind("General", "Enabled", true, "Master switch. State continues to remain in this config while disabled."); GateBossSummons = config.Bind("Gates", "GateBossSummons", true, "Require an admin unlock before a configured boss can be summoned."); GateCrafting = config.Bind("Gates", "GateCrafting", true, "Block configured crafting recipes until their boss has been defeated."); MatchRecipeIngredients = config.Bind("Gates", "MatchRecipeIngredients", true, "Apply crafting rules to both recipe output and ingredient prefab names. False checks only the output."); AdminBypass = config.Bind("Gates", "AdminBypass", true, "Allow server admins to bypass local crafting and boss-summon gates."); BossDefinitions = config.Bind("Rules", "BossDefinitions", "eikthyr|Eikthyr|Eikthyr|defeated_eikthyr|deer;elder|The Elder|gd_king|defeated_gdking|gdking;bonemass|Bonemass|Bonemass|defeated_bonemass|;moder|Moder|Dragon|defeated_dragon|;yagluth|Yagluth|GoblinKing|defeated_goblinking|goblinking,defeated_gdking_varguts;queen|The Queen|SeekerQueen|defeated_queen|seekerqueen;fader|Fader|Fader|defeated_fader|", "Semicolon-separated records: id|display name|boss prefab|defeat global key|comma-separated aliases. Order defines pg_unlock next."); KeywordRules = config.Bind("Rules", "KeywordRules", "eikthyr=Bronze;elder=Iron;bonemass=Silver,Obsidian;moder=BlackMetal;yagluth=BlackCore,BlackMarble,Carapace,Mandible,Softtissue,YggdrasilWood,Eitr,ScaleHide,RoyalJelly,Sap,JotunPuffs,Magecap;queen=Flametal,Grausten,AskHide,Asksvin,Morgen,CharredBone,CelestialFeather,CeramicPlate,BellFragment,Blackwood,Proustite,Sulfur,Fiddlehead,Vineberry,SmokePuff,Volture,Bonemaw", "Semicolon-separated bossId=keyword,keyword rules. A case-insensitive substring match gates a recipe."); ExactPrefabRules = config.Bind("Rules", "ExactPrefabRules", "", "Semicolon-separated bossId=prefab,prefab rules. Exact, case-insensitive recipe output/ingredient matching; useful for modded items."); SummonUnlocks = config.Bind("State", "SummonUnlocks", "", "Managed by pg_unlock/pg_lock. Comma-separated boss IDs."); DefeatedBosses = config.Bind("State", "DefeatedBosses", "", "Managed by boss kills and pg_defeat/pg_undefeat. Comma-separated boss IDs."); WebhookUrl = config.Bind("Discord", "WebhookUrl", "", "Discord webhook URL. Empty disables webhook posting."); WebhookServerLabel = config.Bind("Discord", "ServerLabel", "", "Optional server name included in webhook messages."); WebhookPostUnlocks = config.Bind("Discord", "PostBossUnlocks", true, "Post when an admin unlocks a boss summon."); WebhookPostDefeats = config.Bind("Discord", "PostBossDefeats", true, "Post when the game records a configured boss defeat."); AllowNonDiscordWebhookHosts = config.Bind("Discord", "AllowNonDiscordWebhookHosts", false, "Permit HTTPS webhook URLs outside discord.com/discordapp.com."); UnlockMessage = config.Bind("Discord", "UnlockMessage", "\ud83d\udd13 **{boss}** has been unlocked by **{admin}**.", "Tokens: {boss}, {bossId}, {admin}, {server}"); DefeatMessage = config.Bind("Discord", "DefeatMessage", "⚔\ufe0f **{boss}** has been defeated. Its progression tier is now unlocked.", "Tokens: {boss}, {bossId}, {server}"); Watch(Enabled, changed); Watch(GateBossSummons, changed); Watch(GateCrafting, changed); Watch(MatchRecipeIngredients, changed); Watch(AdminBypass, changed); Watch(BossDefinitions, changed); Watch(KeywordRules, changed); Watch(ExactPrefabRules, changed); Watch(WebhookUrl, changed); Watch(WebhookServerLabel, changed); Watch(WebhookPostUnlocks, changed); Watch(WebhookPostDefeats, changed); Watch(AllowNonDiscordWebhookHosts, changed); Watch(UnlockMessage, changed); Watch(DefeatMessage, changed); } private static void Watch(ConfigEntry entry, Action changed) { entry.SettingChanged += delegate { changed(); }; } } internal static class NetworkManager { internal const string SyncRpc = "ProgressionGater.Sync.v1"; internal const string MessageRpc = "ProgressionGater.Message.v1"; internal static void Register(ZRoutedRpc rpc) { rpc.Register("ProgressionGater.Sync.v1", (Action)delegate(long sender, ZPackage package) { if (!IsFromServer(sender)) { Plugin.Log.LogWarning((object)$"Rejected progression snapshot from non-server peer {sender}."); return; } try { ProgressionService.ApplyServerSnapshot(package); } catch (Exception ex) { Plugin.Log.LogError((object)("Invalid progression snapshot: " + ex.Message)); } }); rpc.Register("ProgressionGater.Message.v1", (Action)delegate(long sender, string message) { if (IsFromServer(sender)) { Player localPlayer = Player.m_localPlayer; if (localPlayer != null) { ((Character)localPlayer).Message((MessageType)2, message, 0, (Sprite)null); } } }); AdminCommands.RegisterRpcHandlers(rpc); } internal static bool IsAdmin(ZNetPeer peer) { if (peer == null || (Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer()) { return false; } ISocket socket = peer.m_socket; string text = ((socket != null) ? socket.GetHostName() : null) ?? ""; if (text.Length == 0) { return false; } try { return ZNet.instance.IsAdmin(text); } catch { return false; } } internal static bool CanPeerBypass(long peerId) { if (!ModConfig.AdminBypass.Value || (Object)(object)ZNet.instance == (Object)null) { return false; } if (!ZNet.instance.IsServer()) { return ProgressionService.LocalCanBypass; } return IsAdmin(ZNet.instance.GetPeer(peerId)); } internal static void SendMessage(long peerId, string message) { if (ZRoutedRpc.instance != null) { ZRoutedRpc.instance.InvokeRoutedRPC(peerId, "ProgressionGater.Message.v1", new object[1] { message }); } } internal static bool IsFromServer(long sender) { if ((Object)(object)ZNet.instance == (Object)null || ZNet.instance.IsServer()) { return false; } ZNetPeer serverPeer = ZNet.instance.GetServerPeer(); if (serverPeer != null && serverPeer.m_uid == sender) { return true; } try { long value = Traverse.Create((object)ZRoutedRpc.instance).Field("m_serverPeerID").GetValue(); return value != 0L && sender == value; } catch { return false; } } } [BepInPlugin("com.catosaur.progressiongater", "Progression Gater", "0.1.1")] public sealed class Plugin : BaseUnityPlugin { public const string Guid = "com.catosaur.progressiongater"; public const string Name = "Progression Gater"; public const string Version = "0.1.1"; private Harmony _harmony; private FileSystemWatcher _configWatcher; private DateTime _lastWatcherEventUtc = DateTime.MinValue; private volatile bool _reloadRequested; internal static Plugin Instance { get; private set; } internal static ManualLogSource Log { get; private set; } private void Awake() { //IL_0039: Unknown result type (might be due to invalid IL or missing references) //IL_0043: Expected O, but got Unknown Instance = this; Log = ((BaseUnityPlugin)this).Logger; ModConfig.Bind(((BaseUnityPlugin)this).Config, RequestConfigReload); ProgressionService.Initialize(); SetupConfigWatcher(); _harmony = new Harmony("com.catosaur.progressiongater"); _harmony.PatchAll(typeof(ProgressionPatches)); _harmony.PatchAll(typeof(AdminCommands)); Log.LogInfo((object)string.Format("{0} {1} loaded with {2} progression bosses.", "Progression Gater", "0.1.1", ProgressionService.Rules.Bosses.Count)); } private void Update() { if (!_reloadRequested) { return; } _reloadRequested = false; try { ((BaseUnityPlugin)this).Config.Reload(); ProgressionService.ReloadRules(); ProgressionService.Broadcast(); Log.LogInfo((object)"Configuration reloaded and synchronized to connected players."); } catch (Exception arg) { Log.LogError((object)$"Configuration reload failed: {arg}"); } } private void RequestConfigReload() { _reloadRequested = true; } private void SetupConfigWatcher() { try { string configFilePath = ((BaseUnityPlugin)this).Config.ConfigFilePath; _configWatcher = new FileSystemWatcher(Path.GetDirectoryName(configFilePath), Path.GetFileName(configFilePath)) { NotifyFilter = (NotifyFilters.Size | NotifyFilters.LastWrite), EnableRaisingEvents = true }; _configWatcher.Changed += delegate { DateTime utcNow = DateTime.UtcNow; if (!((utcNow - _lastWatcherEventUtc).TotalMilliseconds < 500.0)) { _lastWatcherEventUtc = utcNow; RequestConfigReload(); } }; } catch (Exception ex) { Log.LogWarning((object)("Config live-reload watcher could not be started: " + ex.Message)); } } internal static void SaveConfig() { Plugin instance = Instance; if (instance != null) { ((BaseUnityPlugin)instance).Config.Save(); } } private void OnDestroy() { _configWatcher?.Dispose(); Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchAll("com.catosaur.progressiongater"); } } } [HarmonyPatch] internal static class ProgressionPatches { [HarmonyPatch(/*Could not decode attribute arguments.*/)] [HarmonyPostfix] private static void OnRoutedRpcCreated(ZRoutedRpc __instance) { if (__instance != null) { ProgressionService.ClearServerSnapshot(); NetworkManager.Register(__instance); } } [HarmonyPatch(typeof(ZRoutedRpc), "AddPeer")] [HarmonyPostfix] private static void OnPeerAdded(ZNetPeer peer) { if (ProgressionService.IsServer && peer != null) { ProgressionService.SendToPeer(peer); } } [HarmonyPatch(typeof(ZoneSystem), "RPC_SetGlobalKey")] [HarmonyPostfix] private static void OnGlobalKeySet(string name) { if (!ProgressionService.IsServer || !ProgressionService.Enabled || string.IsNullOrWhiteSpace(name)) { return; } try { BossDefinition bossDefinition = ProgressionService.Rules.ResolveGlobalKey(name); if (bossDefinition != null && ProgressionService.MarkDefeated(bossDefinition)) { Plugin.Log.LogInfo((object)("Boss defeat recorded from global key '" + name + "': " + bossDefinition.DisplayName)); DiscordWebhook.PostBossDefeated(bossDefinition); } } catch (Exception arg) { Plugin.Log.LogError((object)$"Boss-defeat processing failed for key '{name}': {arg}"); } } [HarmonyPatch(typeof(Player), "HaveRequirements", new Type[] { typeof(Recipe), typeof(bool), typeof(int), typeof(int) })] [HarmonyPrefix] private static bool OnHaveRequirements(Player __instance, Recipe recipe, ref bool __result) { if (!ProgressionService.GateCrafting || ProgressionService.LocalCanBypass || (Object)(object)__instance == (Object)null || (Object)(object)recipe?.m_item == (Object)null) { return true; } if (ProgressionService.Rules.RequiredBosses(recipe, ProgressionService.MatchIngredients).FirstOrDefault((BossDefinition boss) => !ProgressionService.IsDefeated(boss)) == null) { return true; } __result = false; return false; } [HarmonyPatch(typeof(OfferingBowl), "UseItem")] [HarmonyPrefix] private static bool OnOfferingBowlUseItem(OfferingBowl __instance, Humanoid user, ref bool __result) { if (!ProgressionService.GateBossSummons || ProgressionService.LocalCanBypass) { return true; } BossDefinition bossDefinition = ProgressionService.Rules.Resolve(__instance); if (bossDefinition == null || ProgressionService.IsSummonUnlocked(bossDefinition)) { return true; } if (user != null) { ((Character)user).Message((MessageType)2, bossDefinition.DisplayName + " is locked. A server admin must unlock this boss before it can be summoned.", 0, (Sprite)null); } __result = false; return false; } [HarmonyPatch(typeof(OfferingBowl), "RPC_SpawnBoss")] [HarmonyPrefix] private static bool OnOfferingBowlSpawnBoss(OfferingBowl __instance, long senderId) { if (!ProgressionService.GateBossSummons) { return true; } BossDefinition bossDefinition = ProgressionService.Rules.Resolve(__instance); if (bossDefinition == null || ProgressionService.IsSummonUnlocked(bossDefinition) || NetworkManager.CanPeerBypass(senderId)) { return true; } Plugin.Log.LogWarning((object)$"Blocked locked boss summon: boss={bossDefinition.Id}, sender={senderId}"); NetworkManager.SendMessage(senderId, bossDefinition.DisplayName + " is locked. A server admin must unlock this boss before it can be summoned."); return false; } } internal static class ProgressionService { internal const int ProtocolVersion = 1; private static RuleCatalog _rules; private static HashSet _clientSummonUnlocks = NewSet(); private static HashSet _clientDefeats = NewSet(); private static bool _clientEnabled; private static bool _clientGateBossSummons; private static bool _clientGateCrafting; private static bool _clientMatchIngredients; private static bool _clientCanBypass; private static bool _hasServerSnapshot; internal static RuleCatalog Rules => _rules ?? (_rules = RuleCatalog.FromConfig()); internal static bool IsServer { get { if ((Object)(object)ZNet.instance != (Object)null) { return ZNet.instance.IsServer(); } return false; } } internal static bool Enabled { get { if (!IsServer && _hasServerSnapshot) { return _clientEnabled; } return ModConfig.Enabled.Value; } } internal static bool GateBossSummons { get { if (Enabled) { if (!IsServer && _hasServerSnapshot) { return _clientGateBossSummons; } return ModConfig.GateBossSummons.Value; } return false; } } internal static bool GateCrafting { get { if (Enabled) { if (!IsServer && _hasServerSnapshot) { return _clientGateCrafting; } return ModConfig.GateCrafting.Value; } return false; } } internal static bool MatchIngredients { get { if (!IsServer && _hasServerSnapshot) { return _clientMatchIngredients; } return ModConfig.MatchRecipeIngredients.Value; } } internal static bool LocalCanBypass { get { if (!IsServer) { if (_hasServerSnapshot) { return _clientCanBypass; } return false; } if (ModConfig.AdminBypass.Value) { if (!((Object)(object)Player.m_localPlayer == (Object)null)) { return ZNet.instance.LocalPlayerIsAdminOrHost(); } return true; } return false; } } internal static void Initialize() { _rules = RuleCatalog.FromConfig(); } internal static void ReloadRules() { if (IsServer || (Object)(object)ZNet.instance == (Object)null) { _rules = RuleCatalog.FromConfig(); } } internal static bool IsSummonUnlocked(BossDefinition boss) { if (boss != null) { return CurrentSummonUnlocks().Contains(boss.Id); } return false; } internal static bool IsDefeated(BossDefinition boss) { if (boss != null) { return CurrentDefeats().Contains(boss.Id); } return false; } internal static bool Unlock(BossDefinition boss) { if (!IsServer || boss == null) { return false; } HashSet hashSet = ParseState(ModConfig.SummonUnlocks.Value); if (!hashSet.Add(boss.Id)) { return false; } ModConfig.SummonUnlocks.Value = SerializeState(hashSet); Plugin.SaveConfig(); Broadcast(); return true; } internal static bool Lock(BossDefinition boss) { if (!IsServer || boss == null) { return false; } HashSet hashSet = ParseState(ModConfig.SummonUnlocks.Value); if (!hashSet.Remove(boss.Id)) { return false; } ModConfig.SummonUnlocks.Value = SerializeState(hashSet); Plugin.SaveConfig(); Broadcast(); return true; } internal static bool MarkDefeated(BossDefinition boss) { if (!IsServer || boss == null) { return false; } HashSet hashSet = ParseState(ModConfig.DefeatedBosses.Value); if (!hashSet.Add(boss.Id)) { return false; } ModConfig.DefeatedBosses.Value = SerializeState(hashSet); Plugin.SaveConfig(); Broadcast(); return true; } internal static bool UnmarkDefeated(BossDefinition boss) { if (!IsServer || boss == null) { return false; } HashSet hashSet = ParseState(ModConfig.DefeatedBosses.Value); if (!hashSet.Remove(boss.Id)) { return false; } ModConfig.DefeatedBosses.Value = SerializeState(hashSet); Plugin.SaveConfig(); Broadcast(); return true; } internal static void Reset() { if (IsServer) { ModConfig.SummonUnlocks.Value = ""; ModConfig.DefeatedBosses.Value = ""; Plugin.SaveConfig(); Broadcast(); } } internal static BossDefinition NextBoss() { return Rules.Bosses.FirstOrDefault((BossDefinition boss) => !IsDefeated(boss)); } internal static string Status(BossDefinition boss) { return boss.DisplayName + ": summon " + (IsSummonUnlocked(boss) ? "unlocked" : "LOCKED") + "; recipes " + (IsDefeated(boss) ? "unlocked" : "locked"); } internal static void SendToPeer(ZNetPeer peer) { //IL_0012: Unknown result type (might be due to invalid IL or missing references) //IL_0018: Expected O, but got Unknown if (IsServer && peer != null && ZRoutedRpc.instance != null) { ZPackage val = new ZPackage(); val.Write(1); val.Write(ModConfig.Enabled.Value); val.Write(ModConfig.GateBossSummons.Value); val.Write(ModConfig.GateCrafting.Value); val.Write(ModConfig.MatchRecipeIngredients.Value); val.Write(ModConfig.AdminBypass.Value && NetworkManager.IsAdmin(peer)); Rules.Write(val); WriteSet(val, CurrentSummonUnlocks()); WriteSet(val, CurrentDefeats()); ZRoutedRpc.instance.InvokeRoutedRPC(peer.m_uid, "ProgressionGater.Sync.v1", new object[1] { val }); } } internal static void Broadcast() { if (!IsServer || (Object)(object)ZNet.instance == (Object)null || ZRoutedRpc.instance == null) { return; } foreach (ZNetPeer peer in ZNet.instance.GetPeers()) { SendToPeer(peer); } } internal static void ApplyServerSnapshot(ZPackage package) { int num = package.ReadInt(); if (num != 1) { throw new InvalidOperationException($"Unsupported protocol {num}; expected {1}."); } _clientEnabled = package.ReadBool(); _clientGateBossSummons = package.ReadBool(); _clientGateCrafting = package.ReadBool(); _clientMatchIngredients = package.ReadBool(); _clientCanBypass = package.ReadBool(); _rules = RuleCatalog.Read(package); _clientSummonUnlocks = ReadSet(package); _clientDefeats = ReadSet(package); _hasServerSnapshot = true; Plugin.Log.LogInfo((object)("Server progression synchronized: summon=[" + string.Join(",", _clientSummonUnlocks) + "], defeated=[" + string.Join(",", _clientDefeats) + "]")); } internal static void ClearServerSnapshot() { _hasServerSnapshot = false; _clientCanBypass = false; _rules = RuleCatalog.FromConfig(); } private static HashSet CurrentSummonUnlocks() { if (!IsServer && _hasServerSnapshot) { return _clientSummonUnlocks; } return ParseState(ModConfig.SummonUnlocks.Value); } private static HashSet CurrentDefeats() { if (!IsServer && _hasServerSnapshot) { return _clientDefeats; } return ParseState(ModConfig.DefeatedBosses.Value); } private static HashSet ParseState(string value) { HashSet hashSet = NewSet(); string[] array = (value ?? "").Split(new char[1] { ',' }); for (int i = 0; i < array.Length; i++) { string text = array[i].Trim(); if (text.Length > 0) { hashSet.Add(text); } } return hashSet; } private static string SerializeState(HashSet state) { return string.Join(",", from boss in Rules.Bosses where state.Contains(boss.Id) select boss.Id); } private static HashSet NewSet() { return new HashSet(StringComparer.OrdinalIgnoreCase); } private static void WriteSet(ZPackage package, HashSet state) { package.Write(state.Count); foreach (string item in state) { package.Write(item); } } private static HashSet ReadSet(ZPackage package) { int num = package.ReadInt(); if (num < 0 || num > 1000) { throw new InvalidOperationException("Invalid state count in server snapshot."); } HashSet hashSet = NewSet(); for (int i = 0; i < num; i++) { hashSet.Add(package.ReadString()); } return hashSet; } } internal sealed class RuleCatalog { internal List Bosses { get; } = new List(); internal static RuleCatalog FromConfig() { RuleCatalog ruleCatalog = Parse(ModConfig.BossDefinitions.Value); ApplyRules(ruleCatalog, ModConfig.KeywordRules.Value, exact: false); ApplyRules(ruleCatalog, ModConfig.ExactPrefabRules.Value, exact: true); return ruleCatalog; } private static RuleCatalog Parse(string raw) { RuleCatalog ruleCatalog = new RuleCatalog(); HashSet hashSet = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string item in Split(raw, ';')) { string[] array = item.Split(new char[1] { '|' }); if (array.Length < 4) { Plugin.Log.LogWarning((object)("Ignoring malformed boss definition: " + item)); continue; } string text = array[0].Trim(); string text2 = array[1].Trim(); string text3 = array[2].Trim(); string text4 = array[3].Trim(); if (text.Length == 0 || text2.Length == 0 || text3.Length == 0 || text4.Length == 0 || !hashSet.Add(text)) { Plugin.Log.LogWarning((object)("Ignoring invalid or duplicate boss definition: " + item)); continue; } ruleCatalog.Bosses.Add(new BossDefinition { Id = text, DisplayName = text2, PrefabName = text3, GlobalKey = text4, Aliases = ((array.Length >= 5) ? Split(array[4], ',').ToArray() : Array.Empty()) }); } if (ruleCatalog.Bosses.Count == 0 && !string.Equals(raw, "eikthyr|Eikthyr|Eikthyr|defeated_eikthyr|deer;elder|The Elder|gd_king|defeated_gdking|gdking;bonemass|Bonemass|Bonemass|defeated_bonemass|;moder|Moder|Dragon|defeated_dragon|;yagluth|Yagluth|GoblinKing|defeated_goblinking|goblinking,defeated_gdking_varguts;queen|The Queen|SeekerQueen|defeated_queen|seekerqueen;fader|Fader|Fader|defeated_fader|", StringComparison.Ordinal)) { Plugin.Log.LogError((object)"No valid boss definitions were configured; loading built-in defaults."); return Parse("eikthyr|Eikthyr|Eikthyr|defeated_eikthyr|deer;elder|The Elder|gd_king|defeated_gdking|gdking;bonemass|Bonemass|Bonemass|defeated_bonemass|;moder|Moder|Dragon|defeated_dragon|;yagluth|Yagluth|GoblinKing|defeated_goblinking|goblinking,defeated_gdking_varguts;queen|The Queen|SeekerQueen|defeated_queen|seekerqueen;fader|Fader|Fader|defeated_fader|"); } return ruleCatalog; } private static void ApplyRules(RuleCatalog catalog, string raw, bool exact) { foreach (string item in Split(raw, ';')) { int num = item.IndexOf('='); if (num <= 0) { Plugin.Log.LogWarning((object)("Ignoring malformed " + (exact ? "exact" : "keyword") + " rule: " + item)); continue; } string text = item.Substring(0, num).Trim(); BossDefinition bossDefinition = catalog.Resolve(text); if (bossDefinition == null) { Plugin.Log.LogWarning((object)("Ignoring rule for unknown boss ID '" + text + "'.")); continue; } foreach (string item2 in Split(item.Substring(num + 1), ',')) { if (exact) { bossDefinition.ExactPrefabs.Add(item2); } else { bossDefinition.Keywords.Add(item2); } } } } private static IEnumerable Split(string value, char separator) { return from part in (value ?? "").Split(new char[1] { separator }, StringSplitOptions.RemoveEmptyEntries) select part.Trim() into part where part.Length > 0 select part; } internal BossDefinition Resolve(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } string needle = Normalize(value); return Bosses.FirstOrDefault((BossDefinition boss) => EqualsIgnoreCase(boss.Id, needle) || EqualsIgnoreCase(boss.DisplayName, needle) || EqualsIgnoreCase(boss.PrefabName, needle) || EqualsIgnoreCase(boss.GlobalKey, needle) || boss.Aliases.Any((string alias) => EqualsIgnoreCase(alias, needle))); } internal BossDefinition ResolveGlobalKey(string value) { if (string.IsNullOrWhiteSpace(value)) { return null; } string needle = Normalize(value); return Bosses.FirstOrDefault((BossDefinition boss) => EqualsIgnoreCase(boss.GlobalKey, needle) || boss.Aliases.Any((string alias) => alias.StartsWith("defeated_", StringComparison.OrdinalIgnoreCase) && EqualsIgnoreCase(alias, needle))); } internal BossDefinition Resolve(OfferingBowl bowl) { if ((Object)(object)bowl == (Object)null) { return null; } try { GameObject value = Traverse.Create((object)bowl).Field("m_bossPrefab").Value; return ((Object)(object)value == (Object)null) ? null : Resolve(((Object)value).name); } catch (Exception ex) { Plugin.Log.LogWarning((object)("Could not identify offering-bowl boss: " + ex.Message)); return null; } } internal IEnumerable RequiredBosses(Recipe recipe, bool includeIngredients) { HashSet names = new HashSet(StringComparer.OrdinalIgnoreCase); if ((Object)(object)recipe?.m_item != (Object)null) { names.Add(Normalize(((Object)recipe.m_item).name)); } if (includeIngredients && recipe?.m_resources != null) { Requirement[] resources = recipe.m_resources; foreach (Requirement val in resources) { if ((Object)(object)val?.m_resItem != (Object)null) { names.Add(Normalize(((Object)val.m_resItem).name)); } } } foreach (BossDefinition boss in Bosses) { if (names.Any((string name) => boss.ExactPrefabs.Contains(name) || boss.Keywords.Any((string keyword) => name.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0))) { yield return boss; } } } internal void Write(ZPackage package) { package.Write(Bosses.Count); foreach (BossDefinition boss in Bosses) { package.Write(boss.Id); package.Write(boss.DisplayName); package.Write(boss.PrefabName); package.Write(boss.GlobalKey); WriteStrings(package, boss.Aliases); WriteStrings(package, boss.Keywords); WriteStrings(package, boss.ExactPrefabs); } } internal static RuleCatalog Read(ZPackage package) { RuleCatalog ruleCatalog = new RuleCatalog(); int num = package.ReadInt(); if (num < 0 || num > 100) { throw new InvalidOperationException("Invalid boss count in server snapshot."); } for (int i = 0; i < num; i++) { BossDefinition bossDefinition = new BossDefinition { Id = package.ReadString(), DisplayName = package.ReadString(), PrefabName = package.ReadString(), GlobalKey = package.ReadString(), Aliases = ReadStrings(package) }; string[] array = ReadStrings(package); foreach (string item in array) { bossDefinition.Keywords.Add(item); } array = ReadStrings(package); foreach (string item2 in array) { bossDefinition.ExactPrefabs.Add(item2); } ruleCatalog.Bosses.Add(bossDefinition); } return ruleCatalog; } private static void WriteStrings(ZPackage package, IEnumerable values) { string[] array = values.ToArray(); package.Write(array.Length); string[] array2 = array; foreach (string text in array2) { package.Write(text); } } private static string[] ReadStrings(ZPackage package) { int num = package.ReadInt(); if (num < 0 || num > 1000) { throw new InvalidOperationException("Invalid rule count in server snapshot."); } string[] array = new string[num]; for (int i = 0; i < num; i++) { array[i] = package.ReadString(); } return array; } private static string Normalize(string value) { return (value ?? "").Replace("(Clone)", "").Trim(); } private static bool EqualsIgnoreCase(string left, string right) { return string.Equals(Normalize(left), Normalize(right), StringComparison.OrdinalIgnoreCase); } } }