using System; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Collections.Generic; using System.Linq; using Oxide.Core; using Oxide.Core.Plugins; using UnityEngine; using static TechTreeData; namespace Oxide.Plugins { [Info("Tech Tree Control", "WhiteThunder", "0.6.0")] [Description("Allows customizing Tech Tree research requirements.")] internal class TechTreeControl : CovalencePlugin { #region Fields [PluginReference] private readonly Plugin PopupNotifications; private const string PermissionAnyOrderLevel1 = "techtreecontrol.anyorder.level1"; private const string PermissionAnyOrderLevel2 = "techtreecontrol.anyorder.level2"; private const string PermissionAnyOrderLevel3 = "techtreecontrol.anyorder.level3"; private const string PermissionAnyOrderIO = "techtreecontrol.anyorder.io"; private readonly object False = false; private Configuration _config; #endregion #region Hooks private void Init() { permission.RegisterPermission(PermissionAnyOrderLevel1, this); permission.RegisterPermission(PermissionAnyOrderLevel2, this); permission.RegisterPermission(PermissionAnyOrderLevel3, this); permission.RegisterPermission(PermissionAnyOrderIO, this); _config.Init(this); } private void OnServerInitialized() { if (_config.EnablePopupNotifications && PopupNotifications == null) { LogWarning("PopupNotifications integration is enabled in the config, but the PopupNotifications plugin isn't loaded."); } } private object OnTechTreeNodeUnlock(Workbench workbench, NodeInstance requestedNode, BasePlayer player) { var techTree = FindTechTreeForNode(workbench, requestedNode); if (techTree == null || requestedNode.itemDef == null) return null; var blueprintRuleset = _config.GetPlayerBlueprintRuleset(this, player.UserIDString); var hasAnyOrderPermission = HasPermissionToUnlockAny(player, techTree); // Get all nodes required to unlock (including intermediate nodes). using var nodesToUnlock = Facepunch.Pool.Get>(); techTree.GetNodesRequiredToUnlock(player, requestedNode, nodesToUnlock); // Remove nodes that are already unlocked or have no itemDef (vanilla behavior). for (var i = nodesToUnlock.Count - 1; i >= 0; i--) { var nodeToCheck = nodesToUnlock[i]; if (nodeToCheck.itemDef == null || player.blueprints.HasUnlocked(nodeToCheck.itemDef)) { nodesToUnlock.RemoveAt(i); } } // If player has "any order" permission or doesn't need prerequisites for the target node, // only unlock the target node (skip intermediate nodes). if (hasAnyOrderPermission || (blueprintRuleset != null && !blueprintRuleset.HasPrerequisites(requestedNode.itemDef))) { nodesToUnlock.Clear(); nodesToUnlock.Add(requestedNode); } // Check if all nodes are allowed by the ruleset. if (blueprintRuleset != null) { if (blueprintRuleset.HasOptionals) { // Remove intermediate optional nodes (besides the requested one) before checking blocked nodes. // Players will have to manually unlock allowed optional nodes. for (var i = nodesToUnlock.Count - 1; i >= 0; i--) { var nodeToCheck = nodesToUnlock[i]; if (nodeToCheck != requestedNode && blueprintRuleset.IsOptional(nodeToCheck.itemDef)) { nodesToUnlock.RemoveAt(i); } } } foreach (var nodeToCheck in nodesToUnlock) { if (blueprintRuleset.IsAllowed(nodeToCheck.itemDef)) continue; var message = GetMessage(player.UserIDString, blueprintRuleset.IsOptional(nodeToCheck.itemDef) ? LangEntry.BlueprintDisallowedOptional : LangEntry.BlueprintDisallowed); if (_config.EnablePopupNotifications) { PopupNotifications?.Call("CreatePopupNotification", message, player); } if (_config.EnableChatFeedback) { player.ChatMessage(message); } return False; } } var totalCost = DetermineUnlockCost(techTree, nodesToUnlock, out var currencyItemId); // Check if player has enough currency. if (player.inventory.GetAmount(currencyItemId) < totalCost) return False; // Take currency. if (totalCost > 0) { player.inventory.Take(null, currencyItemId, totalCost); } using var unlockedItemDefinitions = Facepunch.Pool.Get>(); foreach (var nodeToUnlock in nodesToUnlock) { unlockedItemDefinitions.Add(nodeToUnlock.itemDef); if (nodeToUnlock.IsGroup()) { foreach (var outputId in nodeToUnlock.outputs) { var outputNode = techTree.GetByID(outputId); if (outputNode != null && outputNode.itemDef != null) { player.blueprints.Unlock(outputNode.itemDef); } } } } if (unlockedItemDefinitions.Count > 0) { // Call hooks to match default behavior. player.blueprints.UnlockList(unlockedItemDefinitions); Interface.CallHook("OnTechTreeNodeUnlocked", workbench, requestedNode, player, unlockedItemDefinitions); foreach (var itemDefinition in unlockedItemDefinitions) { Interface.CallHook("OnTechTreeNodeUnlocked", workbench, itemDefinition, player); } } return False; } private object OnResearchCostDetermine(ItemDefinition itemDefinition) { return _config.GetResearchCostOverride(itemDefinition); } #endregion #region Helper Methods public static void LogError(string message) => Interface.Oxide.LogError($"[Tech Tree Control] {message}"); public static void LogWarning(string message) => Interface.Oxide.LogWarning($"[Tech Tree Control] {message}"); // Since hooks don't know which tech tree level was requested, find whichever tech tree contains the requested node. private static TechTreeData FindTechTreeForNode(Workbench workbench, NodeInstance node) { var techTreeList = workbench.GetTechTrees(); if (techTreeList == null) return null; foreach (var techTree in techTreeList) { if (techTree.nodes.Contains(node)) return techTree; } return null; } private bool HasPermissionToUnlockAny(BasePlayer player, TechTreeData techTree) { if (techTree.name == "TechTreeT3") return permission.UserHasPermission(player.UserIDString, PermissionAnyOrderLevel3); if (techTree.name == "TechTreeT2") return permission.UserHasPermission(player.UserIDString, PermissionAnyOrderLevel2); if (techTree.name == "TechTreeT0") return permission.UserHasPermission(player.UserIDString, PermissionAnyOrderLevel1); if (techTree.name == "TechTreeIO") return permission.UserHasPermission(player.UserIDString, PermissionAnyOrderIO); return false; } private int DetermineUnlockCost(TechTreeData techTree, List nodesToUnlock, out int currencyItemId) { // Calculate total cost. var totalCost = 0; currencyItemId = _config.IsCustomCurrencyEnabledAndValid ? _config.CustomCurrency.ItemId : ItemManager.FindItemDefinition("scrap").itemid; foreach (var nodeToUnlock in nodesToUnlock) { if (nodeToUnlock.itemDef == null) continue; var costOverride = _config.GetResearchCostOverride(nodeToUnlock.itemDef); int nodeCost; if (costOverride is int overrideCost) { nodeCost = overrideCost; var taxRate = ConVar.Server.GetTaxRateForWorkbenchUnlock(techTree.techTreeLevel); if (taxRate > 0) { nodeCost += Mathf.CeilToInt(nodeCost * (taxRate / 100f)); } } else { nodeCost = Workbench.ScrapForResearch(nodeToUnlock.itemDef, techTree.techTreeLevel, out var tax); nodeCost += tax; } totalCost += nodeCost; } return totalCost; } #endregion #region Configuration [JsonObject(MemberSerialization.OptIn)] private class CustomCurrency { [JsonProperty("Enabled")] public bool Enabled; [JsonProperty("Item short name")] public string ItemShortName = "scrap"; [JsonIgnore] public int ItemId; [JsonIgnore] public bool IsEnabledAndValid => Enabled && ItemId != 0; public void Init() { if (!Enabled) return; var itemDefinition = ItemManager.FindItemDefinition(ItemShortName); if (itemDefinition == null) { LogWarning($"Invalid item short name in config: {ItemShortName}"); } else { ItemId = itemDefinition.itemid; } } } [JsonObject(MemberSerialization.OptIn)] private class BlueprintRuleset { public static readonly BlueprintRuleset DefaultRuleset = new(); [JsonProperty("Name")] private string Name; [JsonProperty("Optional blueprints")] private string[] OptionalBlueprints = Array.Empty(); [JsonProperty("OptionalBlueprints")] private string[] DeprecatedOptionalBlueprints { set => OptionalBlueprints = value; } [JsonProperty("Allowed blueprints", DefaultValueHandling = DefaultValueHandling.Ignore)] private string[] AllowedBlueprints; [JsonProperty("AllowedBlueprints")] private string[] DeprecatedAllowedBlueprints { set => AllowedBlueprints = value; } [JsonProperty("Disallowed blueprints", DefaultValueHandling = DefaultValueHandling.Ignore)] private string[] DisallowedBlueprints; [JsonProperty("DisallowedBlueprints")] private string[] DeprecatedDisallowedBlueprints { set => DisallowedBlueprints = value; } [JsonProperty("Blueprints with no prerequisites")] private string[] BlueprintsWithNoPrerequisites = Array.Empty(); [JsonProperty("BlueprintsWithNoPrerequisites")] private string[] DeprecatedBlueprintsWithNoPrerequisites { set => BlueprintsWithNoPrerequisites = value; } public string Permission { get; private set; } private HashSet _optionalBlueprints = new(); private HashSet _allowedBlueprints = new(); private HashSet _disallowedBlueprints = new(); private HashSet _blueprintsWithNoPrerequisites = new(); public bool HasOptionals => _optionalBlueprints.Count > 0; public void Init(TechTreeControl plugin) { if (!string.IsNullOrWhiteSpace(Name)) { Permission = $"{nameof(TechTreeControl)}.ruleset.{Name}".ToLower(); plugin.permission.RegisterPermission(Permission, plugin); } CacheItemIds(OptionalBlueprints, _optionalBlueprints); CacheItemIds(AllowedBlueprints, _allowedBlueprints); CacheItemIds(DisallowedBlueprints, _disallowedBlueprints); CacheItemIds(BlueprintsWithNoPrerequisites, _blueprintsWithNoPrerequisites); } public bool HasPrerequisites(ItemDefinition itemDefinition) { return !_blueprintsWithNoPrerequisites.Contains(itemDefinition.itemid); } public bool IsAllowed(ItemDefinition itemDefinition) { if (AllowedBlueprints != null) return _allowedBlueprints.Contains(itemDefinition.itemid); if (DisallowedBlueprints != null) return !_disallowedBlueprints.Contains(itemDefinition.itemid); return true; } public bool IsOptional(ItemDefinition itemDefinition) { return _optionalBlueprints.Contains(itemDefinition.itemid); } private static void CacheItemIds(IEnumerable shortNameList, HashSet cachedItemIds) { if (shortNameList == null) return; foreach (var itemShortName in shortNameList) { var itemDefinition = ItemManager.FindItemDefinition(itemShortName); if (itemDefinition == null) { LogError($"Invalid item short name in config: {itemShortName}"); continue; } cachedItemIds.Add(itemDefinition.itemid); } } } [JsonObject(MemberSerialization.OptIn)] private class Configuration : BaseConfiguration { [JsonProperty("Enable chat feedback")] public bool EnableChatFeedback = true; [JsonProperty("Enable PopupNotifications integration")] public bool EnablePopupNotifications; [JsonProperty("Research costs")] private Dictionary ResearchCosts = new(); [JsonProperty("Custom currency")] public CustomCurrency CustomCurrency = new(); [JsonProperty("ResearchCosts")] private Dictionary DeprecatedResearchCosts { set => ResearchCosts = value; } [JsonProperty("Blueprint rulesets")] private BlueprintRuleset[] BlueprintRulesets = Array.Empty(); [JsonProperty("BlueprintRulesets")] private BlueprintRuleset[] DeprecatedBlueprintRulesets { set => BlueprintRulesets = value; } private Dictionary _researchCostByItemId = new(); [JsonIgnore] public bool IsCustomCurrencyEnabledAndValid => CustomCurrency is { IsEnabledAndValid: true }; public void Init(TechTreeControl plugin) { CustomCurrency?.Init(); if (BlueprintRulesets != null) { foreach (var ruleset in BlueprintRulesets) { ruleset.Init(plugin); } } if (ResearchCosts != null) { foreach (var entry in ResearchCosts) { var itemDefinition = ItemManager.FindItemDefinition(entry.Key); if (itemDefinition == null) { LogError($"Invalid item short name in config: {entry.Key}"); continue; } _researchCostByItemId[itemDefinition.itemid] = entry.Value; } } } public object GetResearchCostOverride(ItemDefinition itemDefinition) { return _researchCostByItemId.TryGetValue(itemDefinition.itemid, out var costOverride) ? costOverride : null; } public BlueprintRuleset GetPlayerBlueprintRuleset(TechTreeControl plugin, string userIdString) { if (BlueprintRulesets != null) { for (var i = BlueprintRulesets.Length - 1; i >= 0; i--) { var ruleset = BlueprintRulesets[i]; if (ruleset.Permission != null && plugin.permission.UserHasPermission(userIdString, ruleset.Permission)) return ruleset; } } return BlueprintRuleset.DefaultRuleset; } } private Configuration GetDefaultConfig() => new(); #region Configuration Helpers private class BaseConfiguration { public string ToJson() => JsonConvert.SerializeObject(this); public Dictionary ToDictionary() => JsonHelper.Deserialize(ToJson()) as Dictionary; } private static class JsonHelper { public static object Deserialize(string json) => ToObject(JToken.Parse(json)); private static object ToObject(JToken token) { switch (token.Type) { case JTokenType.Object: return token.Children() .ToDictionary(prop => prop.Name, prop => ToObject(prop.Value)); case JTokenType.Array: return token.Select(ToObject).ToList(); default: return ((JValue)token).Value; } } } private bool MaybeUpdateConfig(BaseConfiguration config) { var currentWithDefaults = config.ToDictionary(); var currentRaw = Config.ToDictionary(x => x.Key, x => x.Value); return MaybeUpdateConfigDict(currentWithDefaults, currentRaw); } private bool MaybeUpdateConfigDict(Dictionary currentWithDefaults, Dictionary currentRaw) { var changed = false; foreach (var key in currentWithDefaults.Keys) { if (currentRaw.TryGetValue(key, out var currentRawValue)) { var currentDictValue = currentRawValue as Dictionary; if (currentWithDefaults[key] is Dictionary defaultDictValue) { if (currentDictValue == null) { currentRaw[key] = currentWithDefaults[key]; changed = true; } else if (MaybeUpdateConfigDict(defaultDictValue, currentDictValue)) { changed = true; } } } else { currentRaw[key] = currentWithDefaults[key]; changed = true; } } return changed; } protected override void LoadDefaultConfig() => _config = GetDefaultConfig(); protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) { throw new JsonException(); } if (MaybeUpdateConfig(_config)) { LogWarning("Configuration appears to be outdated; updating and saving"); SaveConfig(); } } catch { LogWarning($"Configuration file {Name}.json is invalid; using defaults"); LoadDefaultConfig(); } } protected override void SaveConfig() { Log($"Configuration changes saved to {Name}.json"); Config.WriteObject(_config, true); } #endregion #endregion #region Localization private class LangEntry { public static readonly List AllLangEntries = new(); public static readonly LangEntry BlueprintDisallowed = new("BlueprintDisallowed", "You don't have permission to unlock that blueprint."); public static readonly LangEntry BlueprintDisallowedOptional = new("BlueprintDisallowed.Optional", "You don't have permission to unlock that blueprint, but it can be skipped."); public string Name; public string English; public LangEntry(string name, string english) { Name = name; English = english; AllLangEntries.Add(this); } } private string GetMessage(string playerId, LangEntry langEntry) => lang.GetMessage(langEntry.Name, this, playerId); private void ChatMessage(BasePlayer player, LangEntry langEntry) => player.ChatMessage(GetMessage(player.UserIDString, langEntry)); protected override void LoadDefaultMessages() { var englishLangKeys = new Dictionary(); foreach (var langEntry in LangEntry.AllLangEntries) { englishLangKeys[langEntry.Name] = langEntry.English; } lang.RegisterMessages(englishLangKeys, this, "en"); } #endregion } }