C#:
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.1")]
[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<PooledList<NodeInstance>>();
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<PooledList<ItemDefinition>>();
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;
}
#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<NodeInstance> 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<string>();
[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<string>();
[JsonProperty("BlueprintsWithNoPrerequisites")]
private string[] DeprecatedBlueprintsWithNoPrerequisites { set => BlueprintsWithNoPrerequisites = value; }
public string Permission { get; private set; }
private HashSet<int> _optionalBlueprints = new();
private HashSet<int> _allowedBlueprints = new();
private HashSet<int> _disallowedBlueprints = new();
private HashSet<int> _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<string> shortNameList, HashSet<int> 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<string, int> ResearchCosts = new();
[JsonProperty("Custom currency")]
public CustomCurrency CustomCurrency = new();
[JsonProperty("ResearchCosts")]
private Dictionary<string, int> DeprecatedResearchCosts { set => ResearchCosts = value; }
[JsonProperty("Blueprint rulesets")]
private BlueprintRuleset[] BlueprintRulesets = Array.Empty<BlueprintRuleset>();
[JsonProperty("BlueprintRulesets")]
private BlueprintRuleset[] DeprecatedBlueprintRulesets { set => BlueprintRulesets = value; }
private Dictionary<int, object> _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.GetValueOrDefault(itemDefinition.itemid);
}
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<string, object> ToDictionary() => JsonHelper.Deserialize(ToJson()) as Dictionary<string, object>;
}
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<JProperty>()
.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<string, object> currentWithDefaults, Dictionary<string, object> currentRaw)
{
var changed = false;
foreach (var key in currentWithDefaults.Keys)
{
if (currentRaw.TryGetValue(key, out var currentRawValue))
{
var currentDictValue = currentRawValue as Dictionary<string, object>;
if (currentWithDefaults[key] is Dictionary<string, object> 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<Configuration>();
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<LangEntry> 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<string, string>();
foreach (var langEntry in LangEntry.AllLangEntries)
{
englishLangKeys[langEntry.Name] = langEntry.English;
}
lang.RegisterMessages(englishLangKeys, this, "en");
}
#endregion
}
}