// #define ENABLE_TESTS using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core.Libraries.Covalence; using Oxide.Game.Rust.Cui; using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Oxide.Core; using Oxide.Core.Libraries; using UnityEngine; #if ENABLE_TESTS using System.Collections; using System.Reflection; using Oxide.Core.Plugins; #endif namespace Oxide.Plugins { [Info("Recycle Manager", "WhiteThunder", "2.1.0")] [Description("Allows customizing recycler speed, input, and output")] internal class RecycleManager : CovalencePlugin { #region Fields private Configuration _config; private const string PermissionAdmin = "recyclemanager.admin"; private const int ScrapItemId = -932201673; private const float ClassicRecycleEfficiency = 0.5f; private const float VanillaMaxItemsInStackFraction = 0.1f; private readonly object True = true; private readonly object False = false; private const int NumInputSlots = 6; private const int NumOutputSlots = 6; private readonly RecyclerComponentManager _recyclerComponentManager = new(); private readonly RecycleEditManager _recycleEditManager; private readonly float[] _recycleTime = new float[1]; private readonly float[] _recycleEfficiency = new float[1]; private static readonly FieldInfo ScrapRemainderField = typeof(Recycler).GetField("scrapRemainder", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); private bool IsEditUIEnabled => !_config.UsingDefaults && _config.EditUISettings.Enabled; #if ENABLE_TESTS private readonly RecycleManagerTests _testRunner; #endif public RecycleManager() { #if ENABLE_TESTS _testRunner = new RecycleManagerTests(this); #endif _recycleEditManager = new RecycleEditManager(this); } #endregion #region Hooks private void Init() { _config.Init(this); _recyclerComponentManager.Init(this); permission.RegisterPermission(PermissionAdmin, this); if (!_config.Speed.Enabled && !_config.Efficiency.Enabled) { Unsubscribe(nameof(OnRecyclerToggle)); } if (!IsEditUIEnabled) { Unsubscribe(nameof(OnLootEntity)); } } private void OnServerInitialized() { #if ENABLE_TESTS _testRunner.Run(); #endif if (IsEditUIEnabled) { foreach (var player in BasePlayer.activePlayerList) { var container = player.inventory.loot.containers.FirstOrDefault(); if (container == null) continue; var recycler = container.entityOwner as Recycler; if ((object)recycler == null) continue; OnLootEntity(player, recycler); } } } private void Unload() { #if ENABLE_TESTS _testRunner.Interrupt(); #endif _recyclerComponentManager.Unload(); _recycleEditManager.Unload(); } // This hook is primarily used to determine whether an item can be placed into the recycler input, // but it's also called when processing each item. private object CanBeRecycled(Item item, Recycler recycler) { if (item == null) return null; if (_config.RestrictedInputItems.IsDisallowed(item)) { // Defensively return null if vanilla would *disallow* recycling, to avoid hook conflicts. return IsVanillaRecyclable(item) && !RecyclableWasBlocked(item, recycler) ? False : null; } if (_config.OverrideOutput.GetBestOverride(item) != null) { // Defensively return null if vanilla would *allow* recycling, to avoid hook conflicts. return IsVanillaRecyclable(item) || RecyclableWasBlocked(item, recycler) ? null : True; } return null; } private object CanRecycle(Recycler recycler, Item item) { return CanBeRecycled(item, recycler); } private void OnRecyclerToggle(Recycler recycler, BasePlayer player) { _recyclerComponentManager.HandleRecyclerToggle(recycler, player); } private object OnItemRecycle(Item item, Recycler recycler) { if (RecycleItemWasBlocked(item, recycler)) return null; var maxItemsInStackFraction = _config.MaxItemsPerRecycle.GetPercent(item) / 100f; var recycleAmount = DetermineConsumptionAmount(recycler, item, maxItemsInStackFraction); if (recycleAmount <= 0) return False; var customIngredientList = _config.OverrideOutput.GetBestOverride(item); if (customIngredientList != null) { item.UseItem(recycleAmount); // Overrides already account for standard recycle efficiency, so only calculate based on item condition. if (PopulateOutputWithOverride(recycler, customIngredientList, recycleAmount, DetermineItemRecycleEfficiency(item, 1f))) { recycler.StopRecycling(); } return False; } // If the item is not vanilla recyclable, and this plugin doesn't have an override, // that probably means another plugin is going to handle recycling it. if (!IsVanillaRecyclable(item)) return null; // Issue: Last known player will always be null if speed and efficiency are both disabled, since toggle hook // is not subscribed to improve performance. var lastKnownPlayer = _recyclerComponentManager.EnsureRecyclerComponent(recycler).Player; var recycleEfficiency = DetermineItemRecycleEfficiency(item, GetRecyclerEfficiency(recycler, lastKnownPlayer, out var vanillaEfficiency)); if (recycleEfficiency == vanillaEfficiency && maxItemsInStackFraction == VanillaMaxItemsInStackFraction && !IsOutputCustomized(item.info.Blueprint)) return null; item.UseItem(recycleAmount); var outputIsFull = PopulateOutputVanilla(_config, recycler, item, recycleAmount, recycleEfficiency); if (outputIsFull || !recycler.HasRecyclable()) { recycler.StopRecycling(); } return False; } private void OnLootEntity(BasePlayer player, Recycler recycler) { if (!recycler.onlyOneUser) return; if (permission.UserHasPermission(player.UserIDString, PermissionAdmin)) { var player2 = player; var recycler2 = recycler; NextTick(() => { var lootingContainer = player2.inventory.loot.containers.FirstOrDefault(); if (lootingContainer == null || lootingContainer != recycler2.inventory) return; _recycleEditManager.HandlePlayerStartedLooting(player2, recycler2); }); } } #endregion #region Exposed Hooks private static class ExposedHooks { public static object OnRecycleManagerItemRecyclable(Item item, Recycler recycler) { return Interface.CallHook("OnRecycleManagerItemRecyclable", item, recycler); } public static object OnRecycleManagerSpeed(Recycler recycler, BasePlayer player, float[] recycleTime) { return Interface.CallHook("OnRecycleManagerSpeed", recycler, player, recycleTime); } public static void OnRecycleManagerEfficiency(Recycler recycler, BasePlayer player, float[] recyclerEfficiency) { Interface.CallHook("OnRecycleManagerEfficiency", recycler, player, recyclerEfficiency); } public static object OnRecycleManagerRecycle(Item item, Recycler recycler) { return Interface.CallHook("OnRecycleManagerRecycle", item, recycler); } } #endregion #region Commands [Command("recyclemanager.add", "recman.add")] private void CommandAddItem(IPlayer player, string cmd, string[] args) { if (!VerifyHasPermission(player, PermissionAdmin) || !VerifyConfigLoaded(player)) return; if (!VerifyValidItemIdOrShortName(player, args.ElementAtOrDefault(0), out var itemDefinition, cmd)) return; if (!_config.OverrideOutput.AddOverride(this, itemDefinition)) { ReplyToPlayer(player, LangEntry.AddExists, itemDefinition.shortname); return; } SaveConfig(); ReplyToPlayer(player, LangEntry.AddSuccess, itemDefinition.shortname); } [Command("recyclemanager.reset", "recman.reset")] private void CommandResetItem(IPlayer player, string cmd, string[] args) { if (!VerifyHasPermission(player, PermissionAdmin) || !VerifyConfigLoaded(player)) return; if (!VerifyValidItemIdOrShortName(player, args.ElementAtOrDefault(0), out var itemDefinition, cmd)) return; _config.OverrideOutput.ResetOverride(this, itemDefinition); SaveConfig(); ReplyToPlayer(player, LangEntry.ResetSuccess, itemDefinition.shortname); } [Command("recyclemanager.ui")] private void CommandEdit(IPlayer player, string cmd, string[] args) { if (player.IsServer || !player.HasPermission(PermissionAdmin)) return; var basePlayer = player.Object as BasePlayer; _recycleEditManager.GetController(basePlayer)?.HandleUICommand(basePlayer, args); } #endregion #region Utilities private readonly struct ReflectionAdapter { private readonly FieldInfo _fieldInfo; private readonly object _object; public ReflectionAdapter(object obj, FieldInfo fieldInfo) { _object = obj; _fieldInfo = fieldInfo; } public T Value { get => _fieldInfo == null ? default : (T)_fieldInfo.GetValue(_object); set => _fieldInfo?.SetValue(_object, value); } public static implicit operator T(ReflectionAdapter adapter) { return adapter.Value; } } #endregion #region Helper Methods - Instance private bool VerifyHasPermission(IPlayer player, string perm) { if (player.HasPermission(perm)) return true; ReplyToPlayer(player, LangEntry.ErrorNoPermission); return false; } private bool VerifyConfigLoaded(IPlayer player) { if (!_config.UsingDefaults) return true; ReplyToPlayer(player, LangEntry.ErrorConfig); return false; } private bool VerifyValidItemIdOrShortName(IPlayer player, string itemArg, out ItemDefinition itemDefinition, string command) { if (itemArg == null) { itemDefinition = (player.Object as BasePlayer)?.GetActiveItem()?.info; if (itemDefinition != null) return true; ReplyToPlayer(player, LangEntry.ItemSyntax, command); return false; } if (int.TryParse(itemArg, out var itemId)) { itemDefinition = ItemManager.FindItemDefinition(itemId); if (itemDefinition != null) return true; } itemDefinition = ItemManager.FindItemDefinition(itemArg); if (itemDefinition != null) return true; ReplyToPlayer(player, LangEntry.ErrorInvalidItem, itemArg); return false; } private float GetRecyclerEfficiency(Recycler recycler, BasePlayer player, out float vanillaEfficiency) { vanillaEfficiency = DetermineVanillaRecycleEfficiency(recycler); _recycleEfficiency[0] = _config.Efficiency.Enabled ? _config.Efficiency.GetRecyclerEfficiency(recycler) : vanillaEfficiency; ExposedHooks.OnRecycleManagerEfficiency(recycler, player, _recycleEfficiency); return Mathf.Max(0, _recycleEfficiency[0]); } private bool TryDetermineRecycleTime(Recycler recycler, BasePlayer player, out float recycleTime) { _recycleTime[0] = _config.Speed.DefaultRecycleTime * _config.Speed.GetTimeMultiplierForPlayer(player); if (_config.Speed.SafeZoneTimeMultiplier != 1 && player.InSafeZone()) { _recycleTime[0] *= _config.Speed.SafeZoneTimeMultiplier; } if (ExposedHooks.OnRecycleManagerSpeed(recycler, player, _recycleTime) is false) { recycleTime = 0; return false; } recycleTime = Math.Max(0, _recycleTime[0]); return true; } private object CallCanBeRecycled(Item item, Recycler recycler) { Unsubscribe(nameof(CanBeRecycled)); var hookResult = Interface.CallHook(nameof(CanBeRecycled), item, recycler); Subscribe(nameof(CanBeRecycled)); return hookResult; } private bool IsOutputCustomized(ItemBlueprint blueprint) { if (blueprint.scrapFromRecycle > 0 && _config.OutputMultipliers.GetOutputMultiplier(ScrapItemId) != 1) return true; foreach (var ingredient in blueprint.ingredients) { // Skip scrap since it's handled separately. if (ingredient.itemDef.itemid == ScrapItemId) continue; if (_config.OutputMultipliers.GetOutputMultiplier(ingredient.itemid) != 1) return true; } return false; } #endregion #region Helper Methods - Static public static void LogError(string message) => Interface.Oxide.LogError($"[Recycle Manager] {message}"); public static void LogWarning(string message) => Interface.Oxide.LogWarning($"[Recycle Manager] {message}"); private static void Swap(ref T a, ref T b) { (a, b) = (b, a); } private static bool IsVanillaRecyclable(Item item) { return item.info.Blueprint != null; } private static bool RecyclableWasBlocked(Item item, Recycler recycler) { return ExposedHooks.OnRecycleManagerItemRecyclable(item, recycler) is false; } private static bool RecycleItemWasBlocked(Item item, Recycler recycler) { return ExposedHooks.OnRecycleManagerRecycle(item, recycler) is false; } private static Item CreateItem(ItemDefinition itemDefinition, int amount, ulong skinId, string displayName = null) { var item = ItemManager.Create(itemDefinition, amount, skinId); if (!string.IsNullOrWhiteSpace(displayName)) { item.name = displayName; } return item; } private static int CalculateOutputAmountVanillaRandom(int recycleAmount, float adjustedIngredientChance) { var outputAmount = 0; // Roll a random number for every item to consume. for (var i = 0; i < recycleAmount; i++) { if (UnityEngine.Random.Range(0f, 1f) <= adjustedIngredientChance) { outputAmount++; } } return outputAmount; } private static int CalculateOutputAmountFast(int recycleAmount, float ingredientAmount) { // To save on performance, don't generate hundreds/thousands/millions of random numbers. var outputAmountDecimal = ingredientAmount * recycleAmount; var outputAmountInt = (int)outputAmountDecimal; // Roll a random number to see if the the remainder should be given. var remainderFractionalOutputAmount = outputAmountDecimal - outputAmountInt; if (remainderFractionalOutputAmount > 0 && UnityEngine.Random.Range(0f, 1f) <= remainderFractionalOutputAmount) { outputAmountInt++; } return outputAmountInt; } private static int CalculateOutputAmountRandom(int recycleAmount, float ingredientAmount, float recycleEfficiency, float outputMultiplier) { var adjustedIngredientAmount = ingredientAmount * outputMultiplier; if (adjustedIngredientAmount <= 1 && recycleAmount <= 100) return CalculateOutputAmountVanillaRandom(recycleAmount, adjustedIngredientAmount * recycleEfficiency); return CalculateOutputAmountFast(recycleAmount, adjustedIngredientAmount * recycleEfficiency); } private static int CalculateOutputAmountNoRandom(int recycleAmount, float ingredientAmount, float recycleEfficiency, float outputMultiplier) { var adjustedIngredientAmount = Mathf.CeilToInt(ingredientAmount * recycleEfficiency) * outputMultiplier; return CalculateOutputAmountFast(recycleAmount, adjustedIngredientAmount); } private static int CalculateOutputAmount(int recycleAmount, float ingredientAmount, float recycleEfficiency, float outputMultiplier = 1) { if (ingredientAmount <= 1) return CalculateOutputAmountRandom(recycleAmount, ingredientAmount, recycleEfficiency, outputMultiplier); return CalculateOutputAmountNoRandom(recycleAmount, ingredientAmount, recycleEfficiency, outputMultiplier); } private static bool AddItemToRecyclerOutput(Recycler recycler, ItemDefinition itemDefinition, int ingredientAmount, ulong skinId = 0, string displayName = null) { var outputIsFull = false; var numStacks = Mathf.CeilToInt(ingredientAmount / (float)itemDefinition.stackable); for (var i = 0; i < numStacks; i++) { var amountForStack = Math.Min(ingredientAmount, itemDefinition.stackable); var outputItem = CreateItem(itemDefinition, amountForStack, skinId, displayName); if (!recycler.MoveItemToOutput(outputItem)) { outputIsFull = true; } ingredientAmount -= amountForStack; if (ingredientAmount <= 0) break; } return outputIsFull; } private static float DetermineVanillaRecycleEfficiency(Recycler recycler) { return recycler.IsSafezoneRecycler() ? recycler.safezoneRecycleEfficiency : recycler.radtownRecycleEfficiency; } private static IngredientInfo[] GetVanillaOutput(ItemDefinition itemDefinition) { if (itemDefinition.Blueprint?.ingredients == null) return Array.Empty(); var ingredientList = new List(); if (itemDefinition.Blueprint.scrapFromRecycle > 0) { var ingredientInfo = new IngredientInfo { ShortName = "scrap", Amount = itemDefinition.Blueprint.scrapFromRecycle, }; ingredientInfo.Init(); ingredientList.Add(ingredientInfo); } foreach (var blueprintIngredient in itemDefinition.Blueprint.ingredients) { if (blueprintIngredient.itemid == ScrapItemId) continue; var amount = blueprintIngredient.amount / itemDefinition.Blueprint.amountToCreate * ClassicRecycleEfficiency; if (amount > 1) { amount = Mathf.CeilToInt(amount); } var ingredientInfo = new IngredientInfo { ShortName = blueprintIngredient.itemDef.shortname, Amount = amount, }; ingredientInfo.Init(); ingredientList.Add(ingredientInfo); } return ingredientList.ToArray(); } private static float DetermineItemRecycleEfficiency(Item item, float recyclerEfficiency) { return item.hasCondition ? Mathf.Clamp01(recyclerEfficiency * Mathf.Clamp(item.conditionNormalized * item.maxConditionNormalized, 0.1f, 1f)) : recyclerEfficiency; } private static int DetermineConsumptionAmount(Recycler recycler, Item item, float maxItemsInStackFraction) { var recycleAmount = 1; if (item.amount > 1) { recycleAmount = Mathf.CeilToInt(Mathf.Min(item.amount, item.MaxStackable() * maxItemsInStackFraction)); // In case the configured multiplier is 0, ensure at least 1 item is recycled. recycleAmount = Math.Max(recycleAmount, 1); } // Call standard Oxide hook for compatibility. if (Interface.CallHook("OnItemRecycleAmount", item, recycleAmount, recycler) is int overrideAmount) return overrideAmount; return recycleAmount; } private static bool PopulateOutputWithOverride(Recycler recycler, IngredientInfo[] customIngredientList, int recycleAmount, float recycleEfficiency = 1, bool forEditor = false) { var outputIsFull = false; foreach (var ingredientInfo in customIngredientList) { if (ingredientInfo.ItemDefinition == null) continue; var ingredientAmount = ingredientInfo.Amount; if (ingredientAmount <= 0) continue; var outputAmount = CalculateOutputAmount(recycleAmount, ingredientAmount, recycleEfficiency); if (outputAmount <= 0) continue; if (forEditor && outputAmount < 1) { outputAmount = 1; } if (AddItemToRecyclerOutput(recycler, ingredientInfo.ItemDefinition, outputAmount, ingredientInfo.SkinId, ingredientInfo.DisplayName)) { outputIsFull = true; } } return outputIsFull; } private static bool PopulateOutputVanilla(Configuration config, Recycler recycler, Item item, int recycleAmount, float recycleEfficiency) { var outputIsFull = false; if (item.info.Blueprint.scrapFromRecycle > 0) { var scrapOutputMultiplier = config.OutputMultipliers.GetOutputMultiplier(ScrapItemId); var scrapAmountDecimal = item.info.Blueprint.scrapFromRecycle * (float)recycleAmount * scrapOutputMultiplier; if (item.MaxStackable() == 1 && item.hasCondition) { scrapAmountDecimal *= item.conditionNormalized; } scrapAmountDecimal *= recycleEfficiency / ClassicRecycleEfficiency; var scrapAmountInt = Mathf.FloorToInt(scrapAmountDecimal); var scrapRemainderForThisCycle = scrapAmountDecimal - scrapAmountInt; if (scrapRemainderForThisCycle > 0) { var scrapRemainderAdapter = new ReflectionAdapter(recycler, ScrapRemainderField); var recyclerScrapRemainder = scrapRemainderAdapter.Value; recyclerScrapRemainder += scrapRemainderForThisCycle; var scrapRemainderToOutput = Mathf.FloorToInt(recyclerScrapRemainder); if (scrapRemainderToOutput > 0) { recyclerScrapRemainder -= scrapRemainderToOutput; scrapAmountInt += scrapRemainderToOutput; } scrapRemainderAdapter.Value = recyclerScrapRemainder; } if (scrapAmountInt >= 1) { var scrapItem = ItemManager.CreateByItemID(ScrapItemId, scrapAmountInt); recycler.MoveItemToOutput(scrapItem); } } foreach (var ingredient in item.info.Blueprint.ingredients) { // Skip scrap since it's handled separately. if (ingredient.itemDef.itemid == ScrapItemId) continue; var ingredientAmount = ingredient.amount / item.info.Blueprint.amountToCreate; if (ingredientAmount <= 0) continue; var outputAmount = CalculateOutputAmount( recycleAmount, ingredientAmount, recycleEfficiency, config.OutputMultipliers.GetOutputMultiplier(ingredient.itemid) ); if (outputAmount <= 0) continue; if (AddItemToRecyclerOutput(recycler, ingredient.itemDef, outputAmount)) { outputIsFull = true; } } return outputIsFull; } #endregion #region UI private enum IdentificationType { Item, Skin, DisplayName, } private enum OutputType { NotRecyclable, Default, Custom, } private enum UICommand { Edit, Reset, Save, Cancel, InputPercentage, ChangeIdentificationType, ChangeOutputType, } [Flags] private enum LayoutOptions { AnchorBottom = 1 << 0, AnchorRight = 1 << 1, Vertical = 1 << 2, } private class LayoutProvider { public const string AnchorBottomLeft = "0 0"; public const string AnchorBottomRight = "1 0"; public const string AnchorTopLeft = "0 1"; public const string AnchorTopRight = "1 1"; public static LayoutProvider Once(float width = 0, float height = 0) { return _reusable.WithOffset().WithDimensions(width, height).WithOptions(0).WithSpacing(0); } private static LayoutProvider _reusable = new(0, 0); private LayoutOptions _options; private string _anchor; private float _x, _y; private float _xSpacing, _ySpacing; private float _width, _height; private bool _isVertical => (_options & LayoutOptions.Vertical) != 0; private bool _isLeftToRight => (_options & LayoutOptions.AnchorRight) == 0; private bool _isTopToBottom => (_options & LayoutOptions.AnchorBottom) == 0; private int _xSign => _isLeftToRight ? 1 : -1; private int _ySign => _isTopToBottom ? -1 : 1; private float XMin, XMax, YMin, YMax; public string AnchorMin => _anchor; public string AnchorMax => _anchor; public string OffsetMin => $"{XMin.ToString()} {YMin.ToString()}"; public string OffsetMax => $"{XMax.ToString()} {YMax.ToString()}"; public LayoutProvider(float width, float height) { WithDimensions(width, height); WithOptions(0); } public LayoutProvider WithDimensions(float width, float height) { _width = width; _height = height; return this; } public LayoutProvider WithOptions(LayoutOptions options) { _options = options; _anchor = DetermineAnchor(); return this; } public LayoutProvider WithSpacing(float x, float y = float.MaxValue) { _xSpacing = x; _ySpacing = y != float.MaxValue ? y : x; return this; } public LayoutProvider WithOffset(float x = 0, float y = 0) { _x = x * _xSign; _y = y * _ySign; return this; } public LayoutProvider Next() { XMin = _x + _xSpacing * _xSign; YMin = _y + _ySpacing * _ySign; XMax = XMin + _width * _xSign; YMax = YMin + _height * _ySign; if (_isVertical) { _y = YMax; } else { _x = XMax; } if (YMin > YMax) { Swap(ref YMin, ref YMax); } if (XMin > XMax) { Swap(ref XMin, ref XMax); } return this; } public CuiRectTransformComponent GetRectTransform() { return new CuiRectTransformComponent { AnchorMin = _anchor, AnchorMax = _anchor, OffsetMin = OffsetMin, OffsetMax = OffsetMax, }; } private string DetermineAnchor() { return _isTopToBottom ? _isLeftToRight ? AnchorTopLeft : AnchorTopRight : _isLeftToRight ? AnchorBottomLeft : AnchorBottomRight; } } private class ButtonColor { public readonly string Color; public readonly string TextColor; public ButtonColor(string color, string textColor) { Color = color; TextColor = textColor; } } private class ButtonColorScheme { public ButtonColor Active; public ButtonColor Enabled; public ButtonColor Disabled; public ButtonColor Get(bool active = false, bool enabled = false) { return active ? Active : enabled ? Enabled : Disabled; } } private class CuiInputFieldComponentHud : CuiInputFieldComponent { [JsonProperty("hudMenuInput", DefaultValueHandling = DefaultValueHandling.Ignore)] public bool HudMenuInput { get; set; } } private class CuiElementRecreate : CuiElement { [JsonProperty("destroyUi", DefaultValueHandling = DefaultValueHandling.Ignore)] public string DestroyUi { get; set; } } private static class EditUI { private const string UIName = "RecycleManager.UI"; private const string EditPanelUIName = "RecycleManager.UI.EditPanel"; private const string PercentagePanelUIName = "RecycleManager.UI.EditPanel.Percentages"; private const string AnchorMin = "0.5 0"; private const string AnchorMax = "0.5 0"; private const float PanelWidth = 380.5f; private const float PanelHeight = 93f; private const float HeaderHeight = 21; private const int ItemPaddingLeft = 6; private const int ItemSize = 58; private const int ItemSpacing = 4; private const string BaseUICommand = "recyclemanager.ui"; private const string TextColor = "0.8 0.8 0.8 1"; private const string BackgroundColor = "0.25 0.25 0.25 1"; private static ButtonColorScheme DefaultButtonColorScheme = new() { Active = new ButtonColor("0.25 0.5 0.75 1", "0.75 0.85 1 1"), Enabled = new ButtonColor("0.4 0.4 0.4 1", "0.71 0.71 0.71 1"), Disabled = new ButtonColor("0.4 0.4 0.4 0.5", "0.71 0.71 0.71 0.5"), }; private static ButtonColorScheme SaveButtonColorScheme = new() { Enabled = new ButtonColor("0.451 0.553 0.271 1", "0.659 0.918 0.2 1"), Disabled = new ButtonColor("0.451 0.553 0.271 0.5", "0.659 0.918 0.2 0.5"), }; private static ButtonColorScheme ResetButtonColorScheme = new() { Enabled = new ButtonColor("0.9 0.5 0.2 1", "1 0.9 0.7 1"), Disabled = new ButtonColor("0.9 0.5 0.2 0.25", "1 0.9 0.7 0.25"), }; public static void DestroyUI(BasePlayer player) { CuiHelper.DestroyUi(player, UIName); } public static void DrawUI(RecycleManager plugin, BasePlayer player, EditState state) { if (state == null) { DrawDefaultUI(plugin, player); } else { DrawEditUI(plugin, player, state); } } private static void DrawDefaultUI(RecycleManager plugin, BasePlayer player) { var elements = CreateContainer(); AddEditButton(elements, plugin, player); CuiHelper.AddUi(player, elements); } private static void DrawEditUI(RecycleManager plugin, BasePlayer player, EditState state) { var elements = CreateContainer(); AddEditPanel(elements, plugin, player, state); CuiHelper.AddUi(player, elements); } private static CuiElementContainer CreateContainer() { var offsetY = 109.5f; var offsetX = 192f; return new CuiElementContainer { new CuiElementRecreate { Parent = "Hud.Menu", Name = UIName, DestroyUi = UIName, Components = { new CuiRectTransformComponent { AnchorMin = AnchorMin, AnchorMax = AnchorMax, OffsetMin = $"{offsetX} {offsetY}", OffsetMax = $"{offsetX} {offsetY}", }, }, }, }; } private static void AddEditButton(CuiElementContainer elements, RecycleManager plugin, BasePlayer player) { var buttonWidth = 80f; var offsetX = PanelWidth - buttonWidth; var offsetY = 266f; elements.Add(new CuiButton { Text = { Text = plugin.GetMessage(player.UserIDString, LangEntry.UIButtonAdmin), Color = SaveButtonColorScheme.Enabled.TextColor, Align = TextAnchor.MiddleCenter, FontSize = 15, }, Button = { Color = SaveButtonColorScheme.Enabled.Color, Command = $"{BaseUICommand} {UICommand.Edit}", FadeIn = 0.1f, }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{offsetX} {offsetY}", OffsetMax = $"{offsetX + buttonWidth} {offsetY + HeaderHeight}", }, }, UIName); } private static void AddEditHeader(CuiElementContainer elements, RecycleManager plugin, BasePlayer player) { var offsetY = 266f; elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiRawImageComponent { Color = BackgroundColor, Sprite = "assets/content/ui/ui.background.tiletex.psd", }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"0 {offsetY}", OffsetMax = $"{PanelWidth} {offsetY + HeaderHeight}", }, }, }); elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiTextComponent { Text = plugin.GetMessage(player.UserIDString, LangEntry.UIHeader), Align = TextAnchor.MiddleLeft, FontSize = 14, Color = TextColor, }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"5 {offsetY}", OffsetMax = $"{5 + PanelWidth} {offsetY + HeaderHeight}", }, }, }); var buttonWidth = 80f; var offsetX = PanelWidth - buttonWidth; elements.Add(new CuiButton { Text = { Text = plugin.GetMessage(player.UserIDString, LangEntry.UIButtonClose), Color = DefaultButtonColorScheme.Enabled.TextColor, Align = TextAnchor.MiddleCenter, FontSize = 15, }, Button = { Color = DefaultButtonColorScheme.Enabled.Color, Command = $"{BaseUICommand} {UICommand.Cancel}", }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{offsetX} {offsetY}", OffsetMax = $"{offsetX + buttonWidth} {offsetY + HeaderHeight}", }, }, UIName); } private static void AddEditPanel(CuiElementContainer elements, RecycleManager plugin, BasePlayer player, EditState state) { elements.Add(new CuiElement { Parent = UIName, Name = EditPanelUIName, Components = { new CuiRawImageComponent { Color = BackgroundColor, Sprite = "assets/content/ui/ui.background.tiletex.psd", }, new CuiRectTransformComponent { AnchorMin = AnchorMin, AnchorMax = AnchorMax, OffsetMin = "0 0", OffsetMax = $"{PanelWidth} {PanelHeight}", }, }, }); AddEditHeader(elements, plugin, player); if (state.BlockedByAnotherPlugin) { elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiTextComponent { Text = plugin.GetMessage(player.UserIDString, LangEntry.UIItemBlocked), Align = TextAnchor.MiddleCenter, FontSize = 12, Color = TextColor, }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1", }, }, }); return; } AddPercentageControllers(elements, state); if (state.InputItem == null) { elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiTextComponent { Text = plugin.GetMessage(player.UserIDString, LangEntry.UIEmptyState), Align = TextAnchor.MiddleCenter, FontSize = 12, Color = TextColor, }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1", }, }, }); return; } AddItemIdentificationControls(elements, plugin, player, state); AddItemAllowedControls(elements, plugin, player, state); AddPrimaryControls(elements, plugin, player, state); } private static void AddPercentageControllers(CuiElementContainer elements, EditState state) { var offsetY = 169; elements.Add(new CuiElement { Name = PercentagePanelUIName, Parent = EditPanelUIName, Components = { new CuiRawImageComponent { Color = BackgroundColor, Sprite = "assets/content/ui/ui.background.tiletex.psd", }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"0 {offsetY}", OffsetMax = $"{PanelWidth} {offsetY + HeaderHeight}", }, }, }); if (state.InputItem == null) return; for (var i = 0; i < 6; i++) { if (state.Chances[i] <= 0) continue; var offsetX = ItemPaddingLeft + i * (ItemSize + ItemSpacing); elements.Add(new CuiElement { Parent = PercentagePanelUIName, Components = { new CuiInputFieldComponentHud { Align = TextAnchor.MiddleCenter, HudMenuInput = true, Text = $"{state.Chances[i]:0.##}%", Color = TextColor, CharsLimit = 6, Command = $"{BaseUICommand} {UICommand.InputPercentage} {i}", }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "0 0", OffsetMin = $"{offsetX} 0", OffsetMax = $"{offsetX + ItemSize} {HeaderHeight}", }, }, }); } } private static void AddButton(CuiElementContainer elements, LayoutProvider layoutProvider, ButtonColorScheme buttonColorScheme, string parent, string text, string command, bool active = false, bool enabled = true) { var buttonColor = buttonColorScheme.Get(active, enabled); elements.Add(new CuiButton { Text = { Text = text, Color = buttonColor.TextColor, Align = TextAnchor.MiddleCenter, FontSize = 12, }, Button = { Color = buttonColor.Color, Command = enabled && !active ? command : null, }, RectTransform = { AnchorMin = layoutProvider.AnchorMin, AnchorMax = layoutProvider.AnchorMax, OffsetMin = layoutProvider.OffsetMin, OffsetMax = layoutProvider.OffsetMax, }, }, parent); } private static void AddItemIdentificationControls(CuiElementContainer elements, RecycleManager plugin, BasePlayer player, EditState state) { var spacingX = 5; var spacingY = 2; var paddingY = 5; var numElements = 4; var columnWidth = PanelWidth / 3f; var elementHeight = (PanelHeight - 2 * paddingY - (numElements - 1) * spacingY) / numElements; var layoutProvider = LayoutProvider.Once(columnWidth - 2 * spacingX, elementHeight) .WithOffset(0, paddingY - spacingY) .WithSpacing(spacingX, spacingY) .WithOptions(LayoutOptions.Vertical); elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiTextComponent { Text = plugin.GetMessage(player.UserIDString, LangEntry.UILabelConfigureBy), Align = TextAnchor.MiddleCenter, FontSize = 12, Color = TextColor, }, layoutProvider.Next().GetRectTransform(), }, }); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonItem), $"{BaseUICommand} {UICommand.ChangeIdentificationType} {IdentificationType.Item}", state.IdentificationType == IdentificationType.Item, state.InputItem.skin != 0); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonSkin), $"{BaseUICommand} {UICommand.ChangeIdentificationType} {IdentificationType.Skin}", state.IdentificationType == IdentificationType.Skin, state.InputItem.skin != 0); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonDisplayName), $"{BaseUICommand} {UICommand.ChangeIdentificationType} {IdentificationType.DisplayName}", state.IdentificationType == IdentificationType.DisplayName, !string.IsNullOrWhiteSpace(state.InputItem.name)); } private static void AddItemAllowedControls(CuiElementContainer elements, RecycleManager plugin, BasePlayer player, EditState state) { var spacingX = 5; var spacingY = 2; var paddingY = 5; var numElements = 4; var columnWidth = PanelWidth / 3f; var elementHeight = (PanelHeight - 2 * paddingY - (numElements - 1) * spacingY) / numElements; var layoutProvider = LayoutProvider.Once(columnWidth - 2 * spacingX, elementHeight) .WithOffset(columnWidth, paddingY - spacingY) .WithSpacing(spacingX, spacingY) .WithOptions(LayoutOptions.Vertical); elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiTextComponent { Text = plugin.GetMessage(player.UserIDString, LangEntry.UILabelOutput), Align = TextAnchor.MiddleCenter, FontSize = 12, Color = TextColor, }, layoutProvider.Next().GetRectTransform(), }, }); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonNotRecyclable), $"{BaseUICommand} {UICommand.ChangeOutputType} {OutputType.NotRecyclable}", state.OutputType == OutputType.NotRecyclable); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonDefaultOutput), $"{BaseUICommand} {UICommand.ChangeOutputType} {OutputType.Default}", state.OutputType == OutputType.Default, state.IdentificationType != IdentificationType.Item || IsVanillaRecyclable(state.InputItem)); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonCustomOutput), $"{BaseUICommand} {UICommand.ChangeOutputType} {OutputType.Custom}", state.OutputType == OutputType.Custom); } private static void AddPrimaryControls(CuiElementContainer elements, RecycleManager plugin, BasePlayer player, EditState state) { var spacingX = 5; var spacingY = 2; var paddingY = 5; var numElements = 4; var columnWidth = PanelWidth / 3f; var elementHeight = (PanelHeight - 2 * paddingY - (numElements - 1) * spacingY) / numElements; var layoutProvider = LayoutProvider.Once(columnWidth - 2 * spacingX, elementHeight) .WithOffset(2 * columnWidth, paddingY - spacingY) .WithSpacing(spacingX, spacingY) .WithOptions(LayoutOptions.Vertical); elements.Add(new CuiElement { Parent = EditPanelUIName, Components = { new CuiTextComponent { Text = plugin.GetMessage(player.UserIDString, LangEntry.UILabelActions), Align = TextAnchor.MiddleCenter, FontSize = 12, Color = TextColor, }, layoutProvider.Next().GetRectTransform(), }, }); AddButton(elements, layoutProvider.Next(), SaveButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonSave), $"{BaseUICommand} {UICommand.Save}", enabled: state.CanSave); AddButton(elements, layoutProvider.Next(), ResetButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonReset), $"{BaseUICommand} {UICommand.Reset}", enabled: state.CanReset); AddButton(elements, layoutProvider.Next(), DefaultButtonColorScheme, EditPanelUIName, plugin.GetMessage(player.UserIDString, LangEntry.UIButtonCancel), $"{BaseUICommand} {UICommand.Cancel}"); } } #endregion #region Edit Controller private class EditState { public Item InputItem; public IdentificationType IdentificationType; public OutputType OutputType; public float[] Chances = new float[NumOutputSlots]; public bool BlockedByAnotherPlugin; public bool CanSave; public bool CanReset; } private class EditController : FacepunchBehaviour { public static EditController AddToRecycler(RecycleManager plugin, RecycleEditManager recycleEditManager, Recycler recycler) { var component = recycler.gameObject.AddComponent(); component._plugin = plugin; component._recycleEditManager = recycleEditManager; component._recycler = recycler; return component; } private RecycleManager _plugin; private RecycleEditManager _recycleEditManager; private Recycler _recycler; private BasePlayer _player; private EditState _editState; private Func _originalCanAcceptItem; private Action _onDirtyDelayed; private bool _pauseAutoChangeOutput; private Configuration _config => _plugin._config; private EditController() { _onDirtyDelayed = OnDirtyDelayed; } public void StartViewing(BasePlayer player) { _player = player; DrawUI(); } public void DestroyImmediate() { DestroyImmediate(this); } private IngredientInfo[] GetSavedIngredients() { return GetOutput(_editState.IdentificationType, _editState.OutputType); } private bool CanSave() { if (_editState.InputItem == null) return false; switch (_editState.OutputType) { case OutputType.NotRecyclable: { if (IsDisallowed()) return false; if (GetOverride() != null) return true; return _editState.IdentificationType == IdentificationType.Item && IsVanillaRecyclable(_editState.InputItem); } case OutputType.Default: { if (IsDisallowed()) return true; if (GetOverride() != null) return true; return false; } case OutputType.Custom: return GetOverride() == null || !GetOutputIngredients().SequenceEqual(GetSavedIngredients()); default: return true; } } private bool CanReset() { if (_editState.InputItem == null) return false; return IsDisallowed() || GetOverride() != null; } private void DrawUI() { if (_editState != null) { _editState.CanSave = CanSave(); _editState.CanReset = CanReset(); } EditUI.DrawUI(_plugin, _player, _editState); } private List GetOutputIngredients() { var customIngredientList = new List(); for (var i = NumInputSlots; i < NumInputSlots + NumOutputSlots; i++) { var item = _recycler.inventory.GetSlot(i); if (item == null) continue; var amount = (float)item.amount; if (amount == 1) { if (_editState.Chances[i - NumInputSlots] == 0) { _editState.Chances[i - NumInputSlots] = 100; } else { amount = _editState.Chances[i - NumInputSlots] / 100f; } } var ingredientInfo = new IngredientInfo { ShortName = item.info.shortname, DisplayName = !string.IsNullOrWhiteSpace(item.name) ? item.name : null, SkinId = item.skin, Amount = amount, }; ingredientInfo.Init(); customIngredientList.Add(ingredientInfo); } return customIngredientList; } public void HandleUICommand(BasePlayer player, string[] args) { var commandTypeArg = args.FirstOrDefault(); if (commandTypeArg == null) return; if (!Enum.TryParse(commandTypeArg, ignoreCase: true, result: out UICommand uiCommand)) return; switch (uiCommand) { case UICommand.Edit: StartEditing(); break; case UICommand.Reset: { _editState.OutputType = OutputType.Default; var changed = false; changed |= _config.RestrictedInputItems.Allow(_editState.InputItem, _editState.IdentificationType); changed |= _config.OverrideOutput.RemoveOverride(_editState.InputItem, _editState.IdentificationType); if (changed) { _plugin.SaveConfig(); } _editState.OutputType = DetermineBestOutputType(); RemoveOutputItems(); PopulateOutputItems(); DrawUI(); break; } case UICommand.Save: { if (_editState.OutputType == OutputType.Custom) { _config.RestrictedInputItems.Allow(_editState.InputItem, _editState.IdentificationType); _config.OverrideOutput.SetOverride(_editState.InputItem, _editState.IdentificationType, GetOutputIngredients().ToArray()); _plugin.SaveConfig(); } else { var changed = _config.OverrideOutput.RemoveOverride(_editState.InputItem, _editState.IdentificationType); if (_editState.OutputType == OutputType.NotRecyclable) { if (_editState.IdentificationType != IdentificationType.Item || IsVanillaRecyclable(_editState.InputItem)) { changed |= _config.RestrictedInputItems.Disallow(_editState.InputItem, _editState.IdentificationType); } } else { changed |= _config.RestrictedInputItems.Allow(_editState.InputItem, _editState.IdentificationType); } if (changed) { _plugin.SaveConfig(); } } RemoveOutputItems(); PopulateOutputItems(); DrawUI(); break; } case UICommand.Cancel: StopEditing(redraw: true); break; case UICommand.InputPercentage: { var slotArg = args.ElementAtOrDefault(1); var amountArg = args.ElementAtOrDefault(2)?.Replace("%", ""); if (slotArg == null || amountArg == null) break; if (!int.TryParse(slotArg, out var slot) || slot < 0 || slot > 5) break; if (!float.TryParse(amountArg, out var chance)) { DrawUI(); break; } chance = Mathf.Clamp(chance, 0.01f, 100); // Since we allow up to 2 decimal places, allow tolerance of half of 3rd decimal place. // This makes it so if the original value is higher precision like 8.333334, clicking into the // field without changing the input doesn't change the underlying value. if (Math.Abs(chance - _editState.Chances[slot]) >= 0.005f) { _editState.Chances[slot] = chance; _pauseAutoChangeOutput = false; HandleChanges(); } DrawUI(); break; } case UICommand.ChangeOutputType: { var allowedArg = args.ElementAtOrDefault(1); if (allowedArg == null) break; if (!Enum.TryParse(allowedArg, out OutputType outputType)) break; if (outputType == _editState.OutputType) break; _editState.OutputType = outputType; if (outputType == OutputType.NotRecyclable) { RemoveOutputItems(); } else { RemoveOutputItems(); PopulateOutputItems(); } DrawUI(); break; } case UICommand.ChangeIdentificationType: { var identifyTypeArg = args.ElementAtOrDefault(1); if (identifyTypeArg == null) break; if (!Enum.TryParse(identifyTypeArg, ignoreCase: true, result: out IdentificationType identificationType)) break; if (_editState.IdentificationType == identificationType) break; _editState.IdentificationType = identificationType; _editState.OutputType = DetermineBestOutputType(); RemoveOutputItems(); PopulateOutputItems(); DrawUI(); break; } } } private IngredientInfo[] GetOutput(IdentificationType identificationType, OutputType outputType) { if (outputType == OutputType.NotRecyclable) return null; if (outputType == OutputType.Custom) { var output = GetOverride(identificationType); if (output != null) return output; } if (identificationType == IdentificationType.DisplayName) return GetOutput(IdentificationType.Skin, DetermineBestOutputType(IdentificationType.Skin)); if (identificationType == IdentificationType.Skin) return GetOutput(IdentificationType.Item, DetermineBestOutputType(IdentificationType.Item)); if (IsVanillaRecyclable(_editState.InputItem)) return GetVanillaOutput(_editState.InputItem.info); return null; } private void PopulateOutputItems() { var output = GetOutput(_editState.IdentificationType, _editState.OutputType); if (output == null) { for (var i = 0; i < _editState.Chances.Length; i++) { _editState.Chances[i] = 0; } return; } PopulateOutputWithOverride(_recycler, output, 1, forEditor: true); _pauseAutoChangeOutput = true; for (var i = 0; i < _editState.Chances.Length; i++) { _editState.Chances[i] = 0; var customIngredient = output.ElementAtOrDefault(i); if (customIngredient is { Amount: <= 1 }) { _editState.Chances[i] = customIngredient.Amount * 100f; } } } private void RemoveInputItems() { for (var i = 0; i < NumInputSlots; i++) { var item = _recycler.inventory.GetSlot(i); if (item == null) continue; _player.GiveItem(item); } } private void RemoveOutputItems(BasePlayer player = null) { for (var i = NumInputSlots; i < NumInputSlots + NumOutputSlots; i++) { var item = _recycler.inventory.GetSlot(i); if (item == null) continue; if (player != null) { player.GiveItem(item); } else { item.RemoveFromContainer(); item.Remove(); } } _pauseAutoChangeOutput = true; } private void StartEditing() { _recycler.StopRecycling(); RemoveOutputItems(_player); _originalCanAcceptItem = _recycler.inventory.canAcceptItem; _recycler.inventory.canAcceptItem = (item, slot) => _editState?.InputItem != null ? (slot == 0 || slot >= NumInputSlots) : slot < NumInputSlots; _recycler.inventory.onDirty += OnDirty; _editState = new EditState(); OnDirtyDelayed(); } private bool CanAcceptItem(Item item) { if (IsDisallowed()) return false; if (_plugin.CallCanBeRecycled(item, _recycler) is bool result) return result; if (GetOverride() != null) return true; return IsVanillaRecyclable(_editState.InputItem); } private void StopEditing(bool redraw = false) { if (_editState == null || _recycler == null || _recycler.IsDestroyed) return; _recycler.inventory.canAcceptItem = _originalCanAcceptItem; _recycler.inventory.onDirty -= OnDirty; if (_editState.InputItem != null && !CanAcceptItem(_editState.InputItem)) { RemoveInputItems(); } RemoveOutputItems(); _editState = null; if (redraw) { DrawUI(); } } private void StopViewing() { if (_player == null) return; EditUI.DestroyUI(_player); _recycleEditManager.HandlePlayerStoppedLooting(_player); _player = null; } private Item TrimInputs() { Item firstItem = null; for (var i = 0; i < NumInputSlots; i++) { var item = _recycler.inventory.GetSlot(i); if (item == null) continue; if (firstItem == null) { firstItem = item; } else { item.RemoveFromContainer(); _player.GiveItem(item); } } if (firstItem != null && firstItem.position != 0) { firstItem.MoveToContainer(firstItem.parent, 0); } return firstItem; } private void RemoveExcessInput(Item item) { if (item.amount <= 1) return; var splitItem = item.SplitItem(item.amount - 1); if (splitItem == null) return; _player.GiveItem(splitItem); } private IngredientInfo[] GetOverride(IdentificationType identificationType) { return _config.OverrideOutput.GetOverride(_editState.InputItem, identificationType); } private IngredientInfo[] GetOverride() { return GetOverride(_editState.IdentificationType); } private bool IsDisallowed(IdentificationType identificationType) { if (_editState.InputItem == null) return false; return _config.RestrictedInputItems.IsDisallowed(_editState.InputItem, identificationType); } private bool IsDisallowed() { return IsDisallowed(_editState.IdentificationType); } private OutputType DetermineBestOutputType(IdentificationType identificationType) { if (GetOverride(identificationType) != null) return OutputType.Custom; if (IsDisallowed(identificationType)) return OutputType.NotRecyclable; if (identificationType == IdentificationType.Item && !IsVanillaRecyclable(_editState.InputItem)) return OutputType.NotRecyclable; return OutputType.Default; } private OutputType DetermineBestOutputType() { return DetermineBestOutputType(_editState.IdentificationType); } private IdentificationType DetermineBestIdentificationType() { if (GetOverride(IdentificationType.DisplayName) != null || IsDisallowed(IdentificationType.DisplayName)) return IdentificationType.DisplayName; if (GetOverride(IdentificationType.Skin) != null || IsDisallowed(IdentificationType.Skin)) return IdentificationType.Skin; return IdentificationType.Item; } private void HandleNewInputItem() { RemoveExcessInput(_editState.InputItem); RemoveOutputItems(); if (_plugin.CallCanBeRecycled(_editState.InputItem, _recycler) is false) { _editState.BlockedByAnotherPlugin = true; DrawUI(); } else { _editState.BlockedByAnotherPlugin = false; _editState.IdentificationType = DetermineBestIdentificationType(); _editState.OutputType = DetermineBestOutputType(); PopulateOutputItems(); } DrawUI(); } private void HandleChanges() { var output = GetOutputIngredients(); if (!_pauseAutoChangeOutput) { var customOutput = GetOverride(); var defaultOutput = GetOutput(_editState.IdentificationType, OutputType.Default); if (output.Count == 0) { _editState.OutputType = OutputType.NotRecyclable; } else if (customOutput != null && output.SequenceEqual(customOutput)) { _editState.OutputType = OutputType.Custom; } else if (defaultOutput != null && output.SequenceEqual(defaultOutput)) { _editState.OutputType = OutputType.Default; } else { _editState.OutputType = OutputType.Custom; } } for (var i = 0; i < _editState.Chances.Length; i++) { var outputItem = _recycler.inventory.GetSlot(NumInputSlots + i); if (outputItem == null || outputItem.amount > 1) { _editState.Chances[i] = 0; } } } private void OnDirtyDelayed() { if (_recycler == null) { DestroyImmediate(); return; } var inputItem = TrimInputs(); var previousInputItem = _editState.InputItem; _editState.InputItem = inputItem; if (inputItem != null && inputItem == previousInputItem) { RemoveExcessInput(inputItem); HandleChanges(); DrawUI(); return; } if (inputItem == null) { _editState.BlockedByAnotherPlugin = false; RemoveOutputItems(previousInputItem == null ? _player : null); DrawUI(); return; } HandleNewInputItem(); } private void OnDirty() { _pauseAutoChangeOutput = false; Invoke(_onDirtyDelayed, 0); } private void PlayerStoppedLooting(BasePlayer player) { if (_player == player) { StopEditing(); StopViewing(); } } private void OnDestroy() { StopEditing(); StopViewing(); _recycleEditManager.HandleControllerDestroyed(_recycler); } } #endregion #region Edit Manager private class RecycleEditManager { private RecycleManager _plugin; private Dictionary _controllers = new(); private Dictionary _playerControllers = new(); public RecycleEditManager(RecycleManager plugin) { _plugin = plugin; } public void HandlePlayerStartedLooting(BasePlayer player, Recycler recycler) { var controller = EnsureController(recycler); _playerControllers[player] = controller; controller.StartViewing(player); } public EditController GetController(BasePlayer player) { return _playerControllers.TryGetValue(player, out var editController) ? editController : null; } public EditController EnsureController(Recycler recycler) { var editController = GetController(recycler); if (editController == null) { editController = EditController.AddToRecycler(_plugin, this, recycler); } _controllers[recycler] = editController; return editController; } public void HandlePlayerStoppedLooting(BasePlayer player) { _playerControllers.Remove(player); } public void HandleControllerDestroyed(Recycler recycler) { _controllers.Remove(recycler); } public void Unload() { foreach (var controller in _controllers.Values.ToArray()) { controller.DestroyImmediate(); } } private EditController GetController(Recycler recycler) { return _controllers.TryGetValue(recycler, out var editController) ? editController : null; } } #endregion #region Recycler Component private class RecyclerComponent : FacepunchBehaviour { public static RecyclerComponent AddToRecycler(RecycleManager plugin, RecyclerComponentManager recyclerComponentManager, Recycler recycler) { var component = recycler.gameObject.AddComponent(); component._plugin = plugin; component._recyclerComponentManager = recyclerComponentManager; component._recycler = recycler; component._vanillaRecycleThink = recycler.RecycleThink; return component; } public BasePlayer Player { get; private set; } private RecycleManager _plugin; private RecyclerComponentManager _recyclerComponentManager; private Recycler _recycler; private Action _vanillaRecycleThink; private Action _customRecycleThink; private float _recycleTime; private Configuration _config => _plugin._config; private bool _enableIncrementalRecycling => _config.Speed.EnableIncrementalRecycling; private RecyclerComponent() { _customRecycleThink = CustomRecycleThink; } public void DestroyImmediate() { DestroyImmediate(this); } public void HandleRecyclerToggle(BasePlayer player) { // Delay for the following reasons. // 1. Allow other plugins to block toggle the recycler. // 2. Override other plugins after they start the recycler with custom speed. // If another plugin wants to alter the speed, they should use the hook that will be called on a delay. Invoke(() => HandleRecyclerToggleDelayed(player), 0); } private void HandleRecyclerToggleDelayed(BasePlayer player) { if (_recycler.IsOn()) { Player = player; // Allow other plugins to block this, or to modify recycle time. if (!_plugin.TryDetermineRecycleTime(_recycler, player, out _recycleTime)) return; // Cancel the vanilla recycle invoke, in case another plugin started it. // The custom recycle think method will also cancel it, since it may be started on a delay. _recycler.CancelInvoke(_vanillaRecycleThink); if (_enableIncrementalRecycling) { Invoke(_customRecycleThink, GetItemRecycleTime(GetNextItem())); } else { InvokeRepeating(_customRecycleThink, _recycleTime, _recycleTime); } } else { Player = null; // The recycler is off, but vanilla doesn't know how to turn off custom recycling. CancelInvoke(_customRecycleThink); } } private void CustomRecycleThink() { if (!_recycler.IsOn()) { // Vanilla or another plugin turned off the recycler, so don't process the next item. if (!_enableIncrementalRecycling) { // Incremental recycling is disabled, so we must cancel the repeating custom invoke. CancelInvoke(_customRecycleThink); } return; } // Stop the vanilla invokes if another plugin started them. // Necessary because some plugins will start the vanilla invokes on a delay to change recycler speed. // If another plugin wants to change recycler speed, they should use the hook designed for that. if (_recycler.IsInvoking(_vanillaRecycleThink)) { _recycler.CancelInvoke(_vanillaRecycleThink); } _recycler.RecycleThink(); if (_enableIncrementalRecycling) { var nextItem = GetNextItem(); if (nextItem != null) { Invoke(_customRecycleThink, GetItemRecycleTime(nextItem)); } } } private float GetItemRecycleTime(Item item) { if (_recycleTime == 0) return _recycleTime; return _recycleTime * _config.Speed.GetTimeMultiplierForItem(item); } private Item GetNextItem() { for (var i = 0; i < 6; i++) { var item = _recycler.inventory.GetSlot(i); if (item != null) return item; } return null; } private void OnDestroy() { _recyclerComponentManager.HandleRecyclerComponentDestroyed(_recycler); } } private class RecyclerComponentManager { private RecycleManager _plugin; private readonly Dictionary _recyclerComponents = new(); public void Init(RecycleManager plugin) { _plugin = plugin; } public void Unload() { foreach (var recyclerComponent in _recyclerComponents.Values.ToArray()) { recyclerComponent.DestroyImmediate(); } } public void HandleRecyclerToggle(Recycler recycler, BasePlayer player) { EnsureRecyclerComponent(recycler).HandleRecyclerToggle(player); } public void HandleRecyclerComponentDestroyed(Recycler recycler) { _recyclerComponents.Remove(recycler); } public RecyclerComponent EnsureRecyclerComponent(Recycler recycler) { if (!_recyclerComponents.TryGetValue(recycler, out var recyclerComponent)) { recyclerComponent = RecyclerComponent.AddToRecycler(_plugin, this, recycler); _recyclerComponents[recycler] = recyclerComponent; } return recyclerComponent; } } #endregion #region Configuration private class CaseInsensitiveDictionary : Dictionary { public CaseInsensitiveDictionary() : base(StringComparer.OrdinalIgnoreCase) {} } private class CaseInsensitiveHashSet : HashSet { public CaseInsensitiveHashSet() : base(StringComparer.OrdinalIgnoreCase) {} } [JsonObject(MemberSerialization.OptIn)] private class EditUISettings { [JsonProperty("Enabled")] public bool Enabled = true; } [JsonObject(MemberSerialization.OptIn)] private class PermissionSpeedProfile { [JsonProperty("Permission suffix")] public string PermissionSuffix; [JsonProperty("Recycle time (seconds)")] private float RecycleTime { set => TimeMultiplier = value / 5f; } [JsonProperty("Recycle time multiplier")] public float TimeMultiplier = 1; [JsonIgnore] public string Permission { get; private set; } public void Init(RecycleManager plugin) { if (!string.IsNullOrWhiteSpace(PermissionSuffix)) { Permission = $"{nameof(RecycleManager)}.speed.{PermissionSuffix}".ToLower(); plugin.permission.RegisterPermission(Permission, plugin); } } } [JsonObject(MemberSerialization.OptIn)] private class SpeedSettings { [JsonProperty("Enabled")] public bool Enabled; [JsonProperty("Default recycle time (seconds)")] public float DefaultRecycleTime = 5; [JsonProperty("Recycle time (seconds)")] private float DeprecatedRecycleTime { set => DefaultRecycleTime = value; } [JsonProperty("Recycle time multiplier while in safe zone")] public float SafeZoneTimeMultiplier = 1; [JsonProperty("Recycle time multiplier by item short name (item: multiplier)")] public Dictionary TimeMultiplierByShortName = new(); [JsonProperty("Recycle time multiplier by permission")] public PermissionSpeedProfile[] PermissionSpeedProfiles = { new() { PermissionSuffix = "fast", TimeMultiplier = 0.2f, }, new() { PermissionSuffix = "instant", TimeMultiplier = 0, }, }; [JsonProperty("Speeds requiring permission")] private PermissionSpeedProfile[] DeprecatedSpeedsRequiringPermission { set => PermissionSpeedProfiles = value; } [JsonIgnore] private Permission _permission; [JsonIgnore] private Dictionary _timeMultiplierByItemId = new(); [JsonIgnore] public bool EnableIncrementalRecycling; public void Init(RecycleManager plugin) { _permission = plugin.permission; foreach (var speedProfile in PermissionSpeedProfiles) { speedProfile.Init(plugin); } foreach (var (itemShortName, timeMultiplier) in TimeMultiplierByShortName) { var itemDefinition = ItemManager.FindItemDefinition(itemShortName); if (itemDefinition == null) { LogWarning($"Invalid item short name in config: {itemShortName}"); continue; } if (timeMultiplier == 1) continue; _timeMultiplierByItemId[itemDefinition.itemid] = timeMultiplier; EnableIncrementalRecycling = true; } } public float GetTimeMultiplierForPlayer(BasePlayer player) { for (var i = PermissionSpeedProfiles.Length - 1; i >= 0; i--) { var speedProfile = PermissionSpeedProfiles[i]; if (speedProfile.Permission != null && _permission.UserHasPermission(player.UserIDString, speedProfile.Permission)) { return speedProfile.TimeMultiplier; } } return 1; } public float GetTimeMultiplierForItem(Item item) { if (_timeMultiplierByItemId.TryGetValue(item.info.itemid, out var time)) return time; return 1; } } [JsonObject(MemberSerialization.OptIn)] private class EfficiencySettings { [JsonProperty("Enabled")] public bool Enabled; [JsonProperty("Default recycle efficiency")] public float DefaultRecyclerEfficiency = 0.6f; [JsonProperty("Recycle efficiency while in safe zone")] public float RecyclerEfficiencyWhileInSafeZone = 0.4f; public float GetRecyclerEfficiency(Recycler recycler) { return recycler.IsSafezoneRecycler() ? RecyclerEfficiencyWhileInSafeZone : DefaultRecyclerEfficiency; } } [JsonObject(MemberSerialization.OptIn)] private class MaxItemsPerRecycle { [JsonProperty("Default percent")] public float DefaultPercent = 10f; [JsonProperty("Percent by input item short name")] public Dictionary PercentByShortName = new(); [JsonProperty("Percent by input item skin ID")] public Dictionary PercentBySkinId = new(); [JsonProperty("Percent by input item display name (custom items)")] public CaseInsensitiveDictionary PercentByDisplayName = new(); [JsonIgnore] private Dictionary PercentByItemId = new(); public void Init() { foreach (var (shortName, percent) in PercentByShortName) { if (string.IsNullOrWhiteSpace(shortName)) continue; var itemDefinition = ItemManager.FindItemDefinition(shortName); if (itemDefinition == null) { LogWarning($"Invalid item short name in config: {shortName}"); continue; } PercentByItemId[itemDefinition.itemid] = percent; } } public float GetPercent(Item item) { if (!string.IsNullOrWhiteSpace(item.name) && PercentByDisplayName.TryGetValue(item.name, out var multiplier)) return multiplier; if (item.skin != 0 && PercentBySkinId.TryGetValue(item.skin, out multiplier)) return multiplier; if (PercentByItemId.TryGetValue(item.info.itemid, out multiplier)) return multiplier; return DefaultPercent; } } [JsonObject(MemberSerialization.OptIn)] private class OutputMultiplierSettings { [JsonProperty("Default multiplier")] public float DefaultMultiplier = 1f; [JsonProperty("Multiplier by output item short name")] public Dictionary MultiplierByOutputShortName = new(); [JsonIgnore] private Dictionary MultiplierByOutputItemId = new(); public void Init() { foreach (var (shortName, multiplier) in MultiplierByOutputShortName) { if (string.IsNullOrWhiteSpace(shortName)) continue; var itemDefinition = ItemManager.FindItemDefinition(shortName); if (itemDefinition == null) { LogWarning($"Invalid item short name in config: {shortName}"); continue; } MultiplierByOutputItemId[itemDefinition.itemid] = multiplier; } } public float GetOutputMultiplier(int itemId) { if (MultiplierByOutputItemId.TryGetValue(itemId, out var multiplier)) return multiplier; return DefaultMultiplier; } } [JsonObject(MemberSerialization.OptIn)] private class IngredientInfo : IEquatable { [JsonProperty("Item short name")] public string ShortName; [JsonProperty("Item skin ID", DefaultValueHandling = DefaultValueHandling.Ignore)] public ulong SkinId; [JsonProperty("Display name", DefaultValueHandling = DefaultValueHandling.Ignore)] public string DisplayName; [JsonProperty("Amount")] public float Amount; [JsonIgnore] public ItemDefinition ItemDefinition; public void Init() { ItemDefinition = ItemManager.FindItemDefinition(ShortName); if (ItemDefinition == null) { LogWarning($"Invalid item short name in config: {ShortName}"); } if (Amount < 0) { Amount = 0; } } public bool Equals(IngredientInfo other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return ItemDefinition == other.ItemDefinition && SkinId == other.SkinId && Amount.Equals(other.Amount) && string.Compare(DisplayName, other.DisplayName, StringComparison.OrdinalIgnoreCase) == 0; } } [JsonObject(MemberSerialization.OptIn)] private class RestrictedInputItems { [JsonProperty("Item short names")] public CaseInsensitiveHashSet DisallowedInputShortNames = new(); [JsonProperty("Item skin IDs")] public HashSet DisallowedInputSkinIds = new(); [JsonProperty("Item display names (custom items)")] public CaseInsensitiveHashSet DisallowedInputDisplayNames = new(); [JsonIgnore] private HashSet DisallowedInputItemIds = new(); public void Init() { foreach (var shortName in DisallowedInputShortNames) { if (string.IsNullOrWhiteSpace(shortName)) continue; var itemDefinition = ItemManager.FindItemDefinition(shortName); if (itemDefinition == null) { LogWarning($"Invalid item short name in config: {shortName}"); continue; } DisallowedInputItemIds.Add(itemDefinition.itemid); } } public bool IsDisallowed(Item item, IdentificationType identificationType) { switch (identificationType) { case IdentificationType.DisplayName: return DisallowedInputDisplayNames.Contains(item.name); case IdentificationType.Skin: return DisallowedInputSkinIds.Contains(item.skin); case IdentificationType.Item: return DisallowedInputItemIds.Contains(item.info.itemid); } return true; } public bool IsDisallowed(Item item) { if (!string.IsNullOrEmpty(item.name) && IsDisallowed(item, IdentificationType.DisplayName)) return true; if (item.skin != 0 && IsDisallowed(item, IdentificationType.Skin)) return true; return IsDisallowed(item, IdentificationType.Item); } public bool Allow(Item item, IdentificationType identificationType) { switch (identificationType) { case IdentificationType.DisplayName: return DisallowedInputDisplayNames.Remove(item.name); case IdentificationType.Skin: return DisallowedInputSkinIds.Remove(item.skin); case IdentificationType.Item: return DisallowedInputShortNames.Remove(item.info.shortname) | DisallowedInputItemIds.Remove(item.info.itemid); } return false; } public bool Disallow(Item item, IdentificationType identificationType) { switch (identificationType) { case IdentificationType.DisplayName: return DisallowedInputDisplayNames.Add(item.name); case IdentificationType.Skin: return DisallowedInputSkinIds.Add(item.skin); case IdentificationType.Item: return DisallowedInputShortNames.Add(item.info.shortname) | DisallowedInputItemIds.Add(item.info.itemid); } return false; } } [JsonObject(MemberSerialization.OptIn)] private class OverrideOutput { [JsonProperty("Override output by input item short name")] public Dictionary OverrideOutputByShortName = new(); [JsonProperty("Override output by input item skin ID")] public Dictionary OverrideOutputBySkinId = new(); [JsonProperty("Override output by input item display name (custom items)")] public CaseInsensitiveDictionary OverrideOutputByDisplayName = new(); [JsonIgnore] private Dictionary OverrideOutputByItemId = new(); public void Init() { foreach (var (shortName, ingredientInfoList) in OverrideOutputByShortName) { if (string.IsNullOrWhiteSpace(shortName)) continue; var itemDefinition = ItemManager.FindItemDefinition(shortName); if (itemDefinition == null) { LogWarning($"Invalid item short name in config: {shortName}"); continue; } foreach (var ingredientInfo in ingredientInfoList) { ingredientInfo.Init(); } OverrideOutputByItemId[itemDefinition.itemid] = ingredientInfoList; } foreach (var ingredientInfoList in OverrideOutputBySkinId.Values) { foreach (var ingredientInfo in ingredientInfoList) { ingredientInfo.Init(); } } foreach (var ingredientInfoList in OverrideOutputByDisplayName.Values) { foreach (var ingredientInfo in ingredientInfoList) { ingredientInfo.Init(); } } } public IngredientInfo[] GetOverride(Item item, IdentificationType identificationType) { IngredientInfo[] customIngredientList; switch (identificationType) { case IdentificationType.DisplayName: return !string.IsNullOrWhiteSpace(item.name) && OverrideOutputByDisplayName.TryGetValue(item.name, out customIngredientList) ? customIngredientList : null; case IdentificationType.Skin: return item.skin != 0 && OverrideOutputBySkinId.TryGetValue(item.skin, out customIngredientList) ? customIngredientList : null; case IdentificationType.Item: return OverrideOutputByItemId.TryGetValue(item.info.itemid, out customIngredientList) ? customIngredientList : null; } return null; } public IngredientInfo[] GetBestOverride(Item item) { if (!string.IsNullOrWhiteSpace(item.name) && OverrideOutputByDisplayName.TryGetValue(item.name, out var ingredientInfoList)) return ingredientInfoList; if (item.skin != 0 && OverrideOutputBySkinId.TryGetValue(item.skin, out ingredientInfoList)) return ingredientInfoList; if (OverrideOutputByItemId.TryGetValue(item.info.itemid, out ingredientInfoList)) return ingredientInfoList; return null; } public bool AddOverride(RecycleManager plugin,ItemDefinition itemDefinition) { if (OverrideOutputByShortName.ContainsKey(itemDefinition.shortname)) return false; ResetOverride(plugin, itemDefinition); return true; } public void SetOverride(Item item, IdentificationType identificationType, IngredientInfo[] customIngredientList) { switch (identificationType) { case IdentificationType.DisplayName: OverrideOutputByDisplayName[item.name] = customIngredientList; break; case IdentificationType.Skin: OverrideOutputBySkinId[item.skin] = customIngredientList; break; case IdentificationType.Item: OverrideOutputByShortName[item.info.shortname] = customIngredientList; OverrideOutputByItemId[item.info.itemid] = customIngredientList; break; } } public bool RemoveOverride(Item item, IdentificationType identificationType) { switch (identificationType) { case IdentificationType.DisplayName: return OverrideOutputByDisplayName.Remove(item.name); case IdentificationType.Skin: return OverrideOutputBySkinId.Remove(item.skin); case IdentificationType.Item: return OverrideOutputByShortName.Remove(item.info.shortname) | OverrideOutputByItemId.Remove(item.info.itemid); } return false; } public void ResetOverride(RecycleManager plugin,ItemDefinition itemDefinition) { var ingredients = GetVanillaOutput(itemDefinition); OverrideOutputByShortName[itemDefinition.shortname] = ingredients; OverrideOutputByItemId[itemDefinition.itemid] = ingredients; } } [JsonObject(MemberSerialization.OptIn)] private class Configuration : BaseConfiguration { [JsonProperty("Edit UI")] public EditUISettings EditUISettings = new(); [JsonProperty("Custom recycle speed")] public SpeedSettings Speed = new(); [JsonProperty("Custom recycle efficiency")] public EfficiencySettings Efficiency = new(); [JsonProperty("Restricted input items")] public RestrictedInputItems RestrictedInputItems = new(); [JsonProperty("Max items in stack per recycle (% of max stack size)")] public MaxItemsPerRecycle MaxItemsPerRecycle = new(); [JsonProperty("Output multipliers")] public OutputMultiplierSettings OutputMultipliers = new(); [JsonProperty("Override output")] public OverrideOutput OverrideOutput = new(); [JsonProperty("Override output (before efficiency factor)")] private OverrideOutput DeprecatedOverrideOutput { set { if (value == null) return; foreach (var entry in value.OverrideOutputByShortName) { foreach (var ingredientInfo in entry.Value) { ingredientInfo.Amount *= 0.5f; } OverrideOutput.OverrideOutputByShortName[entry.Key] = entry.Value; } foreach (var entry in value.OverrideOutputBySkinId) { foreach (var ingredientInfo in entry.Value) { ingredientInfo.Amount *= 0.5f; } OverrideOutput.OverrideOutputBySkinId[entry.Key] = entry.Value; } foreach (var entry in value.OverrideOutputByDisplayName) { foreach (var ingredientInfo in entry.Value) { ingredientInfo.Amount *= 0.5f; } OverrideOutput.OverrideOutputByDisplayName[entry.Key] = entry.Value; } } } public void Init(RecycleManager plugin) { Speed.Init(plugin); RestrictedInputItems.Init(); MaxItemsPerRecycle.Init(); OutputMultipliers.Init(); OverrideOutput.Init(); } } private Configuration GetDefaultConfig() => new(); #region Configuration Helpers [JsonObject(MemberSerialization.OptIn)] private class BaseConfiguration { [JsonIgnore] public bool UsingDefaults; 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 MaybeUpdateConfigSection(currentWithDefaults, currentRaw); } private bool MaybeUpdateConfigSection(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 (MaybeUpdateConfigSection(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 (Exception e) { LogError(e.Message); LogWarning($"Configuration file {Name}.json is invalid; using defaults"); LoadDefaultConfig(); _config.UsingDefaults = true; } } protected override void SaveConfig() { Log($"Configuration changes saved to {Name}.json"); Config.WriteObject(_config, true); } #endregion #endregion #region Localization private class LangEntry { public static List AllLangEntries = new(); public static readonly LangEntry ErrorNoPermission = new("Error.NoPermission", "You don't have permission to do that."); public static readonly LangEntry ErrorConfig = new("Error.Config", "Error: The config did not load correctly. Please fix the config and reload the plugin before running this command."); public static readonly LangEntry ErrorInvalidItem = new("Error.InvalidItem", "Error: Invalid item: {0}"); public static readonly LangEntry ItemSyntax = new("Item.Syntax", "Syntax: {0} "); public static readonly LangEntry AddExists = new("Add.Exists", "Error: Item {0} is already in the config. To reset that item to vanilla output, use recyclemanager.reset {0}."); public static readonly LangEntry AddSuccess = new("Add.Success", "Successfully added item {0} to the config."); public static readonly LangEntry ResetSuccess = new("Reset.Success", "Successfully reset item {0} in the config."); public static readonly LangEntry UIButtonAdmin = new("UI.Button.Admin", "Admin"); public static readonly LangEntry UIHeader = new("UI.Header", "Recycle Manager"); public static readonly LangEntry UIButtonClose = new("UI.Button.Close", "Close"); public static readonly LangEntry UIEmptyState = new("UI.EmptyState", "Place an item into the recycler to preview and edit its output"); public static readonly LangEntry UIItemBlocked = new("UI.ItemBlocked", "That item is blocked by another plugin"); public static readonly LangEntry UILabelConfigureBy = new("UI.Label.ConfigureBy", "Configure by:"); public static readonly LangEntry UIButtonItem = new("UI.Button.Item", "Item"); public static readonly LangEntry UIButtonSkin = new("UI.Button.Skin", "Skin"); public static readonly LangEntry UIButtonDisplayName = new("UI.Button.DisplayName", "Display name"); public static readonly LangEntry UILabelOutput = new("UI.Label.Output", "Output:"); public static readonly LangEntry UIButtonNotRecyclable = new("UI.Button.NotRecyclable", "Not recyclable"); public static readonly LangEntry UIButtonDefaultOutput = new("UI.Button.DefaultOutput", "Default output"); public static readonly LangEntry UIButtonCustomOutput = new("UI.Button.CustomOutput", "Custom output"); public static readonly LangEntry UILabelActions = new("UI.Label,Actions", "Actions:"); public static readonly LangEntry UIButtonSave = new("UI.Button.Save", "Save"); public static readonly LangEntry UIButtonReset = new("UI.Button.Reset", "Reset"); public static readonly LangEntry UIButtonCancel = new("UI.Button.Cancel", "Cancel"); 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 string GetMessage(string playerId, LangEntry langEntry, object arg1) => string.Format(GetMessage(playerId, langEntry), arg1); private string GetMessage(string playerId, LangEntry langEntry, object arg1, object arg2) => string.Format(GetMessage(playerId, langEntry), arg1, arg2); private string GetMessage(string playerId, LangEntry langEntry, object arg1, object arg2, string arg3) => string.Format(GetMessage(playerId, langEntry), arg1, arg2, arg3); private string GetMessage(string playerId, LangEntry langEntry, params object[] args) => string.Format(GetMessage(playerId, langEntry), args); private void ReplyToPlayer(IPlayer player, LangEntry langEntry) => player.Reply(GetMessage(player.Id, langEntry)); private void ReplyToPlayer(IPlayer player, LangEntry langEntry, object arg1) => player.Reply(GetMessage(player.Id, langEntry, arg1)); private void ReplyToPlayer(IPlayer player, LangEntry langEntry, object arg1, object arg2) => player.Reply(GetMessage(player.Id, langEntry, arg1, arg2)); private void ReplyToPlayer(IPlayer player, LangEntry langEntry, object arg1, object arg2, object arg3) => player.Reply(GetMessage(player.Id, langEntry, arg1, arg2, arg3)); private void ReplyToPlayer(IPlayer player, LangEntry langEntry, params object[] args) => player.Reply(GetMessage(player.Id, langEntry, args)); 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 #region Tests #if ENABLE_TESTS private class RecycleManagerTests : BaseTestSuite { private static void Assert(bool value, string message = null) { if (!value) throw new Exception($"Assertion failed: {message}"); } private static void AssertItemInSlot(ItemContainer container, int slot, out Item item) { item = container.GetSlot(slot); Assert(item != null, $"Expected item in slot {slot}, but found none."); } private static void AssertItemShortName(Item item, string shortName) { Assert(item.info.shortname == shortName, $"Expected item '{shortName}', but found '{item.info.shortname}'."); } private static void AssertItemSkin(Item item, ulong skin) { Assert(item.skin == skin, $"Expected item {item.info.shortname} to have skin '{skin}', but found '{item.skin}'."); } private static void AssertItemDisplayName(Item item, string displayName) { Assert(item.name == displayName, $"Expected item {item.info.shortname} to have display name '{displayName}', but found '{item.name}'."); } private static void AssertItemAmount(Item item, int expectedAmount) { Assert(item.amount == expectedAmount, $"Expected item '{item.info.shortname}' to have amount {expectedAmount}, but found {item.amount}."); } private static void AssertItemInContainer(ItemContainer container, int slot, string shortName, int amount) { AssertItemInSlot(container, slot, out var item); AssertItemShortName(item, shortName); AssertItemAmount(item, amount); } private static Item CreateItem(string shortName, int amount = 1, ulong skin = 0) { var item = ItemManager.CreateByName(shortName, amount, skin); Assert(item != null, $"Failed to create item with short name '{shortName}' and amount '{amount}' and skin '{skin}'."); return item; } private static Item AddItemToContainer(ItemContainer container, string shortName, int amount = 1, ulong skin = 0, int slot = -1) { var item = CreateItem(shortName, amount, skin); if (!item.MoveToContainer(container, slot)) throw new Exception($"Failed to move item '{shortName}' to container."); return item; } private static Action SetMaxStackSize(string shortName, int amount) { var itemDefinition = ItemManager.FindItemDefinition(shortName); Assert(itemDefinition != null, $"Failed to find item definition for short name '{shortName}'."); var originalMaxStackSize = itemDefinition.stackable; itemDefinition.stackable = amount; return () => itemDefinition.stackable = originalMaxStackSize; } private RecycleManager _plugin; private Configuration _originalConfig; private Recycler _recycler; private BasePlayer _player; public RecycleManagerTests(RecycleManager plugin) { _plugin = plugin; } protected override void BeforeAll() { _originalConfig = _plugin._config; _recycler = (Recycler)GameManager.server.CreateEntity("assets/bundled/prefabs/static/recycler_static.prefab", new Vector3(0, -1000, 0)); _recycler.limitNetworking = true; _recycler.Spawn(); _player = (BasePlayer)GameManager.server.CreateEntity("assets/prefabs/player/player.prefab", new Vector3(0, -1000, 0)); _player.limitNetworking = true; _player.modelState.flying = true; _player.Spawn(); } protected override void BeforeEach() { _recycler.inventory.Clear(); ItemManager.DoRemoves(); SetupPlayer(123); } private HashSet GetPluginPermissions() { var registeredPermissionsField = typeof(Permission).GetField("registeredPermissions", BindingFlags.Instance | BindingFlags.NonPublic); var permissionsMap = (Dictionary>)registeredPermissionsField.GetValue(_plugin.permission); return permissionsMap.TryGetValue(_plugin, out var permissionList) ? permissionList : null; } private void InitializePlugin(Configuration config) { _plugin._config = config; GetPluginPermissions()?.Clear(); _recycler.StopRecycling(); // Reset recycle components since they cache the config. _plugin._recycleComponentManager.Unload(); _plugin.Init(); _plugin.OnServerInitialized(); } private void SetupPlayer(ulong userId) { _player.userID = userId; _player.UserIDString = userId.ToString(); foreach (var perm in _plugin.permission.GetUserPermissions(_player.UserIDString).ToArray()) { _plugin.permission.RevokeUserPermission(_player.UserIDString, perm); } } [TestMethod("Given rope is restricted, it should not be allowed in recyclers")] public void Test_ItemRestrictions_ItemShortNames() { InitializePlugin(new Configuration { RestrictedInputItems = new RestrictedInputItems { DisallowedInputShortNames = new CaseInsensitiveHashSet { "rope", }, }, }); // Tarp is not restricted, should be allowed. var tarp = CreateItem("tarp"); if (_recycler.inventory.CanAcceptItem(tarp, 0) != ItemContainer.CanAcceptResult.CanAccept) throw new Exception($"Expected {tarp.info.shortname} to be allowed in recycler, but it was disallowed."); var rope = CreateItem("rope"); if (_recycler.inventory.CanAcceptItem(rope, 0) != ItemContainer.CanAcceptResult.CannotAccept) throw new Exception($"Expected {rope.info.shortname} to be disallowed in recycler, but it was allowed."); } [TestMethod("Given recycle speed disabled, items should recycle after 5 seconds")] public IEnumerator Test_RecycleSpeed_Disabled() { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = false, DefaultRecycleTime = 2, }, }); var gears = AddItemToContainer(_recycler.inventory, "gears"); _recycler.StartRecycling(); yield return new WaitForSeconds(4.9f); AssertItemAmount(gears, 1); yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 0); } [TestMethod("Given default recycle speed 0.1 seconds, items should recycle after 0.1 seconds")] public IEnumerator Test_RecycleSpeed_Enabled() { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.1f, }, }); var gears = AddItemToContainer(_recycler.inventory, "gears"); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; AssertItemAmount(gears, 1); yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 0); } [TestMethod("Given default recycle speed 3 seconds, player permission 0.1 multiplier, items should recycle after 0.3 seconds")] public IEnumerator Test_RecycleSpeed_Permission() { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 3, PermissionSpeedProfiles = new PermissionSpeedProfile[] { new() { PermissionSuffix = "fast", TimeMultiplier = 0.1f }, }, }, }); _plugin.permission.GrantUserPermission(_player.UserIDString, "recyclemanager.speed.fast", _plugin); var gears = AddItemToContainer(_recycler.inventory, "gears"); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; AssertItemAmount(gears, 1); yield return new WaitForSeconds(0.31f); AssertItemAmount(gears, 0); } [TestMethod("Given default recycle time 0.2 seconds, gears 0.5 multiplier, gears should recycle after 0.1 seconds, metalpipes after 0.2 seconds")] public IEnumerator Test_RecycleSpeed() { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.2f, TimeMultiplierByShortName = new Dictionary { ["gears"] = 0.5f, }, }, }); var gears1 = AddItemToContainer(_recycler.inventory, "gears", slot: 0); var pipe1 = AddItemToContainer(_recycler.inventory, "metalpipe", slot: 1); var gears2 = AddItemToContainer(_recycler.inventory, "gears", slot: 2); var pipe2 = AddItemToContainer(_recycler.inventory, "metalpipe", slot: 3); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; yield return new WaitForSeconds(0.11f); AssertItemAmount(gears1, 0); AssertItemAmount(pipe1, 1); AssertItemAmount(gears2, 1); AssertItemAmount(pipe2, 1); yield return new WaitForSeconds(0.2f); AssertItemAmount(pipe1, 0); AssertItemAmount(gears2, 1); AssertItemAmount(pipe2, 1); yield return new WaitForSeconds(0.1f); AssertItemAmount(gears2, 0); AssertItemAmount(pipe2, 1); yield return new WaitForSeconds(0.2f); AssertItemAmount(pipe2, 0); } [TestMethod("Given gears max stack size 100, stack of 3 gears, with no override configured, should output 36 scrap and 48 metal fragments")] public IEnumerator Test_RecycleStacks_NoOverride(List cleanupActions) { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.1f, }, }); cleanupActions.Add(SetMaxStackSize("gears", 100)); var gears = AddItemToContainer(_recycler.inventory, "gears", 3); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 0); AssertItemInContainer(_recycler.inventory, 6, "scrap", 36); AssertItemInContainer(_recycler.inventory, 7, "metal.fragments", 48); } [TestMethod("Given gears max stack size 100, stack of 75 gears, default stack percent 50%, should output 600 scrap & 975 metal fragments, then should output 900 scrap & 1200 metal fragments")] public IEnumerator Test_RecycleStacks_DefaultPercent(List cleanupActions) { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.1f, }, MaxItemsPerRecycle = new MaxItemsPerRecycle { DefaultPercent = 50f, }, }); cleanupActions.Add(SetMaxStackSize("gears", 100)); var gears = AddItemToContainer(_recycler.inventory, "gears", 75); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 25); AssertItemInContainer(_recycler.inventory, 6, "scrap", 600); AssertItemInContainer(_recycler.inventory, 7, "metal.fragments", 800); yield return new WaitForSeconds(0.1f); AssertItemAmount(gears, 0); AssertItemInContainer(_recycler.inventory, 6, "scrap", 900); AssertItemInContainer(_recycler.inventory, 7, "metal.fragments", 1200); } [TestMethod("Given gears max stack size 100, stack of 75 gears, gears stack percent 50%, should output 600 scrap & 975 metal fragments, then should output 900 scrap & 1200 metal fragments")] public IEnumerator Test_RecycleStacks_ShortNamePercent(List cleanupActions) { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.1f, }, MaxItemsPerRecycle = new MaxItemsPerRecycle { DefaultPercent = 10f, PercentByShortName = new Dictionary { ["gears"] = 50, }, }, }); cleanupActions.Add(SetMaxStackSize("gears", 100)); var gears = AddItemToContainer(_recycler.inventory, "gears", 75); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 25); AssertItemInContainer(_recycler.inventory, 6, "scrap", 600); AssertItemInContainer(_recycler.inventory, 7, "metal.fragments", 800); yield return new WaitForSeconds(0.1f); AssertItemAmount(gears, 0); AssertItemInContainer(_recycler.inventory, 6, "scrap", 900); AssertItemInContainer(_recycler.inventory, 7, "metal.fragments", 1200); } [TestMethod("Given 2.0 default multiplier, 3.0 scrap output multiplier, stack of 3 gears, should output 108 scrap & 96 metal fragments")] public IEnumerator Test_OutputMultipliers(List cleanupActions) { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.1f, }, OutputMultipliers = new OutputMultiplierSettings { DefaultMultiplier = 2, MultiplierByOutputShortName = new Dictionary { ["scrap"] = 3f, }, }, }); cleanupActions.Add(SetMaxStackSize("gears", 100)); var gears = AddItemToContainer(_recycler.inventory, "gears", 3); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 0); AssertItemInContainer(_recycler.inventory, 6, "scrap", 108); AssertItemInContainer(_recycler.inventory, 7, "metal.fragments", 96); } [TestMethod("Given override for gears, stack of 3 gears, should output custom items")] public IEnumerator Test_OverrideOutput_ByItemShortName(List cleanupActions) { InitializePlugin(new Configuration { RecycleSpeed = new RecycleSpeed { Enabled = true, DefaultRecycleTime = 0.1f, }, OutputMultipliers = new OutputMultiplierSettings { // Output multipliers should have no effect. DefaultMultiplier = 2, }, OverrideOutput = new OverrideOutput { OverrideOutputByShortName = new CaseInsensitiveDictionary { ["gears"] = new IngredientInfo[] { new() { ShortName = "wood", Amount = 50, SkinId = 123456, DisplayName = "Vood", }, }, }, }, }); cleanupActions.Add(SetMaxStackSize("gears", 100)); var gears = AddItemToContainer(_recycler.inventory, "gears", 3); _plugin.CallHook(nameof(OnRecyclerToggle), _recycler, _player); _recycler.StartRecycling(); yield return null; yield return new WaitForSeconds(0.11f); AssertItemAmount(gears, 0); AssertItemInSlot(_recycler.inventory, 6, out var outputItem); AssertItemShortName(outputItem, "wood"); AssertItemSkin(outputItem, 123456); AssertItemDisplayName(outputItem, "Vood"); AssertItemAmount(outputItem, 150); } protected override void AfterAll(bool interrupted) { if (_recycler != null && !_recycler.IsDestroyed) { _recycler.Kill(); } if (_player != null && !_player.IsDestroyed) { _player.Die(); } if (!interrupted) { InitializePlugin(_originalConfig); } } } #endif #endregion #region Test Runner #if ENABLE_TESTS [AttributeUsage(AttributeTargets.Method)] public class TestMethodAttribute : Attribute { public readonly string Name; public bool Skip; public bool Only; public TestMethodAttribute(string name = null) { Name = name; } } public abstract class BaseTestSuite { private enum TestStatus { Skipped, Running, Success, Error, } private class TestInfo { public string Name; public bool Async; public MethodInfo MethodInfo; public TestMethodAttribute Attribute; public TestStatus Status = TestStatus.Skipped; public bool ShouldSkip; public Exception Exception; } public bool IsRunning { get; private set; } private List _testInfoList = new(); private Coroutine _coroutine; protected virtual void BeforeAll() {} protected virtual void BeforeEach() {} protected virtual void AfterEach() {} protected virtual void AfterAll(bool interrupted) {} public void Run() { if (IsRunning) return; if (!TryRunBeforeAll()) return; var hasOnly = false; foreach (var methodInfo in GetType().GetMethods()) { if (methodInfo.GetCustomAttributes(typeof(TestMethodAttribute), true).FirstOrDefault() is not TestMethodAttribute testMethodAttribute) continue; var isAsync = methodInfo.ReturnType == typeof(IEnumerator); if (!isAsync && methodInfo.ReturnType != typeof(void)) { LogError($"Disallowed return type '{methodInfo.ReturnType.FullName}' for test '{testMethodAttribute.Name}'"); continue; } hasOnly |= testMethodAttribute.Only; _testInfoList.Add(new TestInfo { Name = testMethodAttribute.Name, Async = isAsync, MethodInfo = methodInfo, Attribute = testMethodAttribute, ShouldSkip = testMethodAttribute.Skip, }); } if (hasOnly) { foreach (var testInfo in _testInfoList) { testInfo.ShouldSkip = !testInfo.Attribute.Only; } } var syncTestList = _testInfoList.Where(testInfo => !testInfo.Async).ToArray(); var asyncTestList = _testInfoList.Where(testInfo => testInfo.Async).ToArray(); var canKeepRunning = RunSyncTests(syncTestList); if (!canKeepRunning && asyncTestList.Length == 0) { RunAfterAll(); return; } _coroutine = ServerMgr.Instance.StartCoroutine(RunAsyncTests(asyncTestList)); } public void Interrupt() { if (!IsRunning) return; if (_coroutine != null) { ServerMgr.Instance.StopCoroutine(_coroutine); LogWarning("Interrupted tests."); } RunAfterAll(true); } private bool TryRunBeforeAll() { IsRunning = true; try { BeforeAll(); return true; } catch (Exception ex) { LogError($"Failed to run BeforeAll() for test suite {GetType().FullName}:\n{ex}"); IsRunning = false; return false; } } private bool TryRunBeforeEach() { try { BeforeEach(); return true; } catch (Exception ex) { LogError($"Failed to run BeforeEach() for test suite {GetType().FullName}:\n{ex}"); return false; } } private bool TryRunAfterEach() { try { AfterEach(); return true; } catch (Exception ex) { LogError($"Failed to run AfterEach() for test suite {GetType().FullName}:\n{ex}"); return false; } } private void RunAfterAll(bool interrupted = false) { try { AfterAll(interrupted); } catch (Exception ex) { LogError($"Failed to run AfterAll() method for test suite {GetType().FullName}:\n{ex}"); } IsRunning = false; if (!interrupted) { PrintResults(); } } private bool HasParameter(MethodInfo methodInfo, int parameterIndex = 0) { return methodInfo.GetParameters().ElementAtOrDefault(parameterIndex)?.ParameterType == typeof(T); } private object[] CreateTestArguments(MethodInfo methodInfo, out List cleanupActions) { if (HasParameter>(methodInfo)) { cleanupActions = new List(); return new object[] { cleanupActions }; } cleanupActions = null; return null; } private void RunCleanupActions(List cleanupActions) { if (cleanupActions == null) return; foreach (var action in cleanupActions) { try { action.Invoke(); } catch (Exception ex) { LogError($"Failed to run cleanup action:\n{ex}"); } } } private bool RunSyncTests(IEnumerable syncTestList) { foreach (var testInfo in syncTestList) { if (testInfo.ShouldSkip) continue; if (!TryRunBeforeEach()) return false; var args = CreateTestArguments(testInfo.MethodInfo, out var cleanupActions); try { testInfo.MethodInfo.Invoke(this, args); testInfo.Status = TestStatus.Success; } catch (Exception ex) { testInfo.Exception = ex; testInfo.Status = TestStatus.Error; } RunCleanupActions(cleanupActions); if (!TryRunAfterEach()) return false; } return true; } private IEnumerator RunAsyncTests(IEnumerable asyncTestList) { foreach (var testInfo in asyncTestList) { if (testInfo.ShouldSkip) continue; if (!TryRunBeforeEach()) break; var args = CreateTestArguments(testInfo.MethodInfo, out var cleanupActions); IEnumerator enumerator; try { enumerator = (IEnumerator)testInfo.MethodInfo.Invoke(this, args); } catch (Exception ex) { RunCleanupActions(cleanupActions); testInfo.Exception = ex; testInfo.Status = TestStatus.Error; continue; } testInfo.Status = TestStatus.Running; while (testInfo.Status == TestStatus.Running) { // This assumes Current is null or an instance of YieldInstruction. yield return enumerator.Current; try { if (!enumerator.MoveNext()) { testInfo.Status = TestStatus.Success; break; } } catch (Exception ex) { RunCleanupActions(cleanupActions); testInfo.Exception = ex; testInfo.Status = TestStatus.Error; break; } } RunCleanupActions(cleanupActions); if (!TryRunAfterEach()) break; } RunAfterAll(); } private void PrintResults() { foreach (var testInfo in _testInfoList) { switch (testInfo.Status) { case TestStatus.Success: LogWarning($"[PASSED] {testInfo.Name}"); break; case TestStatus.Skipped: LogWarning($"[SKIPPED] {testInfo.Name}"); break; case TestStatus.Error: LogError($"[FAILED] {testInfo.Name}:\n{testInfo.Exception}"); break; case TestStatus.Running: LogError($"[RUNNING] {testInfo.Name}"); break; default: LogError($"[{testInfo.Status}] {testInfo.Name}"); break; } } } } #endif #endregion } }