diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..cab295f79 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,57 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +# Editor Guidelines VS/Rider extension: 180-col guide +guidelines = 180 + +[*.cs] +indent_size = 4 +tab_width = 4 + +# A BOM is what stops an editor from misreading a UTF-8 file as CP1252 and silently +# round-tripping non-ASCII characters into mojibake (it happened to the tooltip pip +# glyphs in EpicLoot.cs). Prefer \uXXXX escapes for glyphs in string literals anyway. +charset = utf-8-bom + +# --- One True Brace Style (1TBS) --- +csharp_new_line_before_open_brace = none +csharp_new_line_before_else = false +csharp_new_line_before_catch = false +csharp_new_line_before_finally = false +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Always use braces (1TBS mandates them, even on single-line blocks) +csharp_prefer_braces = true:suggestion + +# Block / switch indentation +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_block_contents = true +csharp_indent_braces = false + +# Keep formatting deviations as hints, never warnings/errors +dotnet_diagnostic.IDE0055.severity = suggestion + +# --- Reduce modernization nagging (merged from both old files) --- +dotnet_diagnostic.IDE0003.severity = none # Remove qualification +dotnet_diagnostic.IDE0031.severity = none # Use null propagation +dotnet_diagnostic.IDE0051.severity = none # Remove unused private members +dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter +dotnet_diagnostic.IDE0066.severity = none # Convert switch to expression +dotnet_diagnostic.IDE0074.severity = none # Use compound assignment +dotnet_diagnostic.IDE0090.severity = none # 'new' expression can be simplified +dotnet_diagnostic.IDE0130.severity = none # Namespace doesn't match folder +dotnet_diagnostic.IDE0270.severity = none # Use coalesce expression + +# Localization files are read with BOM detection but must never carry one for +# other tooling; plain UTF-8 is the contract. +[*.{json,md}] +charset = utf-8 + +# --- Generated / third-party Unity code: skip style analysis --- +[ValheimUnity/**.cs] +generated_code = true \ No newline at end of file diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 21c7eba24..22320aacd --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ /**/obj/ /packages/ +# Always ignore the valheim assembly metadata as it gets updated each time a build happens +ValheimUnity/Assets/ExternalLibraries/**.meta +ValheimUnity/Assets/Exports.meta +EpicLoot/ValheimUnity/Assets/ExternalLibraries/EpicLoot.dll + # Never ignore Asset meta data !/[Aa]ssets/**/*.meta @@ -62,13 +67,15 @@ sysinfo.txt *.apk *.unitypackage +# Releases +EpicLoot/Releases/ + # Crashlytics generated file crashlytics-build.properties *.swp /valheim_Data/BepInEx-out/plugins/ExtendedItemDataFramework.dll /valheim_Data/BepInEx-out/plugins/EpicLoot/translations.json -/valheim_Data/BepInEx-out/plugins/EpicLoot/recipes.json /valheim_Data/BepInEx-out/plugins/EpicLoot/magiceffects.json /valheim_Data/BepInEx-out/plugins/EpicLoot/loottables.json /valheim_Data/BepInEx-out/plugins/EpicLoot/legendaries.json @@ -92,3 +99,9 @@ ValheimUnity/Packages/packages-lock.json Common ValheimMods.sln *.env +/EpicLoot/shardstone_testing_matrix.csv +/.gitignore +/Common/Common.projitems +/EpicLoot/EpicLoot.csproj +/EpicLoot/EpicLoot.sln +/EpicLoot/shardstone_testing_matrix.csv diff --git a/AdvancedPortals/AdvancedPortals.csproj b/AdvancedPortals/AdvancedPortals.csproj index 3d8dc0b87..78b66c99b 100644 --- a/AdvancedPortals/AdvancedPortals.csproj +++ b/AdvancedPortals/AdvancedPortals.csproj @@ -51,7 +51,7 @@ - - - + + + \ No newline at end of file diff --git a/AdvancedPortals/README.md b/AdvancedPortals/README.md index 339288a68..d4c3fbd9b 100644 --- a/AdvancedPortals/README.md +++ b/AdvancedPortals/README.md @@ -1,7 +1,7 @@ # Advanced Portals Author: [RandyKnapp](https://discord.gg/ZNhYeavv3C) -Source: [Github](https://github.com/OrianaVenture/Randy_Vapok_ValheimMods/tree/main/AdvancedPortals) +Source: [Github](https://github.com/RandyKnapp/ValheimMods/tree/main/AdvancedPortals) Patreon: [patreon.com/randyknapp](https://www.patreon.com/randyknapp) Discord: [RandyKnapp's Mod Community](https://discord.gg/ZNhYeavv3C) diff --git a/BuildTasks.props b/BuildTasks.props index 6eb505bb9..608a82f8d 100644 --- a/BuildTasks.props +++ b/BuildTasks.props @@ -8,6 +8,11 @@ + + + diff --git a/Common/Common.projitems b/Common/Common.projitems index c0d416dd4..79d3786aa 100644 --- a/Common/Common.projitems +++ b/Common/Common.projitems @@ -9,6 +9,10 @@ Common - + + \ No newline at end of file diff --git a/Common/src/Modding/AssetBundleLoader.cs b/Common/src/Modding/AssetBundleLoader.cs new file mode 100644 index 000000000..db5521aac --- /dev/null +++ b/Common/src/Modding/AssetBundleLoader.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Reflection; +using UnityEngine; + +namespace Common { + /// + /// Loads an asset bundle embedded in a mod assembly as a manifest resource. + /// + /// Replaces the copy of this method that lived in EpicLoot, Jam, AdvancedPortals and + /// EquipmentAndQuickSlots. Those used Assembly.GetCallingAssembly(); that is unsafe here, because the + /// caller can be another type in this shared project rather than the mod, so the assembly is explicit. + /// + public static class AssetBundleLoader { + /// + /// Loads the bundle embedded as "<assembly name>.<filename>". Returns null (and logs) on failure. + /// + public static AssetBundle LoadFromResources(string filename, Assembly assembly) { + if (assembly == null) { throw new ArgumentNullException(nameof(assembly)); } + + string resourceName = $"{assembly.GetName().Name}.{filename}"; + try { + using (Stream stream = assembly.GetManifestResourceStream(resourceName)) { + if (stream == null) { + ModLogger.LogError($"Embedded asset bundle '{resourceName}' not found in {assembly.GetName().Name}."); + return null; + } + return AssetBundle.LoadFromStream(stream); + } + } catch (Exception e) { + ModLogger.LogError($"Failed to load embedded asset bundle '{resourceName}': {e.Message}"); + return null; + } + } + + /// + /// Loads the bundle and assigns it to so the shared loaders + /// can find it without being handed one. + /// + public static AssetBundle LoadIntoContext(string filename, Assembly assembly) { + AssetBundle bundle = LoadFromResources(filename, assembly); + if (bundle != null) { ModContext.AssetBundle = bundle; } + return bundle; + } + } +} diff --git a/Common/src/Modding/ConfigBinder.cs b/Common/src/Modding/ConfigBinder.cs new file mode 100644 index 000000000..655335f37 --- /dev/null +++ b/Common/src/Modding/ConfigBinder.cs @@ -0,0 +1,97 @@ +using BepInEx.Configuration; +using Jotunn.Extensions; + +namespace Common { + /// + /// Binding helpers over . + /// + /// BindServerConfig marks entries IsAdminOnly, which is what makes Jotunn's SynchronizationManager + /// treat them as server-authoritative; BindClientConfig leaves them local to each player. + /// + /// Prefer the ...InOrder variants for new config files: they keep the settings grouped the way the + /// code declares them instead of alphabetised. See the remarks on . + /// + public static class ConfigBinder { + private static ConfigFile Cfg => ModContext.Cfg; + + // -- Server synced (admin only) ------------------------------------------------------------ + + public static ConfigEntry BindServerConfig(string category, string key, bool value, string description, + AcceptableValueBase acceptableValues = null, bool advanced = false) { + return Cfg.Bind(category, key, value, + new ConfigDescription(description, acceptableValues, + new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced })); + } + + public static ConfigEntry BindServerConfig(string category, string key, int value, string description, + bool advanced = false, int valMin = 0, int valMax = 150) { + return Cfg.Bind(category, key, value, + new ConfigDescription(description, new AcceptableValueRange(valMin, valMax), + new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced })); + } + + public static ConfigEntry BindServerConfig(string category, string key, float value, string description, + bool advanced = false, float valMin = 0f, float valMax = 150f) { + return Cfg.Bind(category, key, value, + new ConfigDescription(description, new AcceptableValueRange(valMin, valMax), + new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced })); + } + + public static ConfigEntry BindServerConfig(string category, string key, string value, string description, + AcceptableValueList acceptableValues = null, bool advanced = false) { + return Cfg.Bind(category, key, value, + new ConfigDescription(description, acceptableValues, + new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced })); + } + + /// Generic escape hatch for types without a dedicated overload above. + public static ConfigEntry BindServerConfig(string category, string key, T value, string description, + AcceptableValueBase acceptableValues, bool advanced = false) { + return Cfg.Bind(category, key, value, + new ConfigDescription(description, acceptableValues, + new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced })); + } + + // -- Client local -------------------------------------------------------------------------- + + public static ConfigEntry BindClientConfig(string category, string key, T value, string description, + AcceptableValueBase acceptableValues = null, bool advanced = false) { + return Cfg.Bind(category, key, value, + new ConfigDescription(description, acceptableValues, + new ConfigurationManagerAttributes { IsAdminOnly = false, IsAdvanced = advanced })); + } + + // -- Ordered --------------------------------------------------------------------------------- + + /// + /// Server-synced entry bound through Jotunn's ordered binder. + /// + /// + /// ConfigurationManager sorts sections alphabetically and, within a section, by the Order + /// attribute. Jotunn's BindConfigInOrder exploits both: it prefixes the section with the position + /// it was first bound in ("2 - Balance") and hands each entry a descending Order, so what the + /// player sees matches the grouping the code declares rather than an alphabetical jumble. + /// + /// Two consequences to plan for: + /// - Keep a config file to **nine sections or fewer**. The prefix is not zero padded, so a tenth + /// section sorts as "10 - ..." between "1 - ..." and "2 - ...". + /// - The prefix is part of the section name written to disk, so inserting a section renames every + /// section after it and orphans the player's saved values. Add new sections at the end, or + /// migrate the old names (EpicLoot's ELConfig does the latter). + /// + public static ConfigEntry BindServerConfigInOrder(string category, string key, T value, string description, + AcceptableValueBase acceptableValues = null, bool advanced = false) { + return Cfg.BindConfigInOrder(category, key, value, description, synced: true, + acceptableValues: acceptableValues, + configAttributes: new ConfigurationManagerAttributes { IsAdvanced = advanced }); + } + + /// Client-local counterpart of . + public static ConfigEntry BindClientConfigInOrder(string category, string key, T value, string description, + AcceptableValueBase acceptableValues = null, bool advanced = false) { + return Cfg.BindConfigInOrder(category, key, value, description, synced: false, + acceptableValues: acceptableValues, + configAttributes: new ConfigurationManagerAttributes { IsAdvanced = advanced }); + } + } +} diff --git a/Common/src/Modding/ConfigChangeDebouncer.cs b/Common/src/Modding/ConfigChangeDebouncer.cs new file mode 100644 index 000000000..cd98cf17c --- /dev/null +++ b/Common/src/Modding/ConfigChangeDebouncer.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace Common { + // Debounces config apply work so a burst of rapid SettingChanged events for one entry + // (a user typing into a field, a config file reload, or a server config sync) collapses + // into a single apply once the edits settle, instead of re-doing the heavy work (recipe + // rebuilds, world scans) on every intermediate value. + // + // Mirrors the EnqueueWorldUpdate/DrainWorldUpdates coroutine pattern (BepInEx ThreadingHelper, + // yielding null each frame - WaitForSeconds is not reliably honoured by that runner). No locking; + // like EnqueueWorldUpdate this assumes the config handlers run on the main thread. + public static class ConfigChangeDebouncer { + // Latest action to run per key (the changed ConfigEntry instance). + private static readonly Dictionary pendingActions = new Dictionary(); + // Time (Time.realtimeSinceStartup) at which each key's action should fire. + private static readonly Dictionary fireAt = new Dictionary(); + // Keys with a coroutine already waiting, so we don't start a second one. + private static readonly HashSet running = new HashSet(); + + // Schedules action to run after ModContext.ConfigApplyDelay seconds. Re-calling with the same + // key before it fires replaces the action and resets the timer (true debounce + coalesce). + // A delay <= 0 applies immediately (lets admins disable the delay). + public static void Schedule(object key, Action action) { + float delay = ModContext.ConfigApplyDelay != null ? ModContext.ConfigApplyDelay.Value : 0f; + if (delay <= 0f) { + action(); + return; + } + // During game shutdown the ThreadingHelper's MonoBehaviour is destroyed while config entries can + // still fire SettingChanged (e.g. Jotunn reverting server-synced values on disconnect). Calling + // StartCoroutine on a destroyed behaviour throws ArgumentNullException, and there's nothing left + // to update anyway, so drop the change. The Unity '==' overload treats a destroyed object as null. + BepInEx.ThreadingHelper host = BepInEx.ThreadingHelper.Instance; + if (host == null) { return; } + pendingActions[key] = action; + fireAt[key] = Time.realtimeSinceStartup + delay; + if (running.Contains(key)) { return; } + running.Add(key); + host.StartCoroutine(Run(key)); + } + + private static IEnumerator Run(object key) { + // Wait until the entry has been idle for the full delay; re-scheduling pushes fireAt out. + while (fireAt.TryGetValue(key, out float at) && Time.realtimeSinceStartup < at) { + yield return null; + } + pendingActions.TryGetValue(key, out Action action); + pendingActions.Remove(key); + fireAt.Remove(key); + running.Remove(key); + action?.Invoke(); + } + } +} diff --git a/Common/src/Modding/ConfigDrawers.cs b/Common/src/Modding/ConfigDrawers.cs new file mode 100644 index 000000000..68180303e --- /dev/null +++ b/Common/src/Modding/ConfigDrawers.cs @@ -0,0 +1,420 @@ +using BepInEx.Configuration; +using Jotunn.Managers; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Common { + // Shared IMGUI widgets used by the item/piece config drawers. + // + // Every entity (item or piece) collapses its many ConfigEntries into a single ConfigurationManager + // row backed by a CustomDrawer. The underlying ConfigEntries are untouched (same keys, same sync) - + // only their display is changed: all but one "anchor" entry get Browsable = false so the manager no + // longer lays each of them out every frame, which is what caused the lag. + internal static class ConfigDrawHelpers { + // Edit buffers so partial typing in text/number fields doesn't immediately overwrite the live value. + private static readonly Dictionary TextBuffer = new Dictionary(); + // In-progress slider values; committed to the ConfigEntry only on mouse release to avoid a disk + // write (SaveOnConfigSet is true) on every frame of a drag. + private static readonly Dictionary SliderPending = new Dictionary(); + + private static GUIStyle _headerButton; + private static GUIStyle _groupLabel; + private static GUIStyle _dim; + + internal static GUIStyle HeaderButton => _headerButton; + internal static GUIStyle Dim => _dim; + + internal static void EnsureStyles() { + if (_headerButton != null) { return; } + _headerButton = new GUIStyle(GUI.skin.button) { alignment = TextAnchor.MiddleLeft, fontStyle = FontStyle.Bold }; + _groupLabel = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Bold }; + _dim = new GUIStyle(GUI.skin.label) { fontStyle = FontStyle.Italic, fontSize = 11 }; + } + + // Fetches the Jotunn ConfigurationManagerAttributes already attached by ConfigBinder. + internal static ConfigurationManagerAttributes GetAttributes(ConfigEntryBase entry) => + entry?.Description?.Tags?.OfType().FirstOrDefault(); + + // Hides an entry from the Configuration Manager window without affecting saving or server sync. + internal static void Hide(ConfigEntryBase entry) { + ConfigurationManagerAttributes attr = GetAttributes(entry); + if (attr != null) { attr.Browsable = false; } + } + + internal static void GroupHeader(string text) { + GUILayout.Space(4f); + GUILayout.Label(text, _groupLabel); + } + + private static bool EnterPressed() => + Event.current.type == EventType.KeyDown && + (Event.current.keyCode == KeyCode.Return || Event.current.keyCode == KeyCode.KeypadEnter); + + internal static void DrawBool(string label, ConfigEntry cfg) { + bool v = GUILayout.Toggle(cfg.Value, " " + label); + if (v != cfg.Value) { cfg.Value = v; } + } + + // Free-text string field that commits on Enter or when focus leaves the field (never per keystroke, + // so handlers like "crafting station changed" don't fire/warn on every character). + internal static void DrawString(string label, ConfigEntry cfg) { + GUILayout.BeginHorizontal(); + GUILayout.Label(label, GUILayout.Width(230f)); + string ctrl = "str_" + cfg.GetHashCode(); + GUI.SetNextControlName(ctrl); + bool focused = GUI.GetNameOfFocusedControl() == ctrl; + string shown = focused && TextBuffer.TryGetValue(cfg, out string buf) ? buf : cfg.Value; + string typed = GUILayout.TextField(shown, GUILayout.Width(230f)); + if (focused) { + TextBuffer[cfg] = typed; + if (EnterPressed() && cfg.Value != typed) { cfg.Value = typed; } + } else if (TextBuffer.TryGetValue(cfg, out string pending)) { + if (cfg.Value != pending) { cfg.Value = pending; } + TextBuffer.Remove(cfg); + } + GUILayout.EndHorizontal(); + } + + // Cycler for string entries restricted to an AcceptableValueList (damage modifiers, piece category). + internal static void DrawChoice(string label, ConfigEntry cfg) { + GUILayout.BeginHorizontal(); + GUILayout.Label(label, GUILayout.Width(230f)); + if (cfg.Description.AcceptableValues is AcceptableValueList list && list.AcceptableValues.Length > 0) { + string[] vals = list.AcceptableValues; + int idx = Array.IndexOf(vals, cfg.Value); + if (idx < 0) { idx = 0; } + if (GUILayout.Button("◄", GUILayout.Width(26f))) { cfg.Value = vals[(idx - 1 + vals.Length) % vals.Length]; } + GUILayout.Label(cfg.Value, GUILayout.Width(160f)); + if (GUILayout.Button("►", GUILayout.Width(26f))) { cfg.Value = vals[(idx + 1) % vals.Length]; } + } else { + GUILayout.Label(cfg.Value); + } + GUILayout.EndHorizontal(); + } + + internal static void DrawFloat(string label, ConfigEntry cfg) { + float min = 0f, max = Mathf.Max(100f, cfg.Value); + if (cfg.Description.AcceptableValues is AcceptableValueRange r) { min = r.MinValue; max = r.MaxValue; } + float v = DrawSliderRow(label, cfg, cfg.Value, min, max, false); + if (v != cfg.Value) { cfg.Value = Mathf.Clamp(v, min, max); } + } + + internal static void DrawInt(string label, ConfigEntry cfg) { + float min = 0f, max = Mathf.Max(100f, cfg.Value); + if (cfg.Description.AcceptableValues is AcceptableValueRange r) { min = r.MinValue; max = r.MaxValue; } + float v = DrawSliderRow(label, cfg, cfg.Value, min, max, true); + int iv = Mathf.Clamp(Mathf.RoundToInt(v), (int)min, (int)max); + if (iv != cfg.Value) { cfg.Value = iv; } + } + + // Slider (deferred-commit on mouse release) + a buffered numeric text box (commit on Enter/blur). + // Returns the value the caller should write; never writes the ConfigEntry itself. + private static float DrawSliderRow(string label, object key, float current, float min, float max, bool isInt) { + float result = current; + GUILayout.BeginHorizontal(); + GUILayout.Label(label, GUILayout.Width(230f)); + + float shown = SliderPending.TryGetValue(key, out float pend) ? pend : current; + float slid = GUILayout.HorizontalSlider(shown, min, max, GUILayout.Width(150f)); + if (Mathf.Abs(slid - shown) > Mathf.Epsilon) { SliderPending[key] = slid; } + if (SliderPending.TryGetValue(key, out float dragging)) { + result = dragging; + if (!Input.GetMouseButton(0)) { SliderPending.Remove(key); } + } + + // Numeric text box for exact entry. + string ctrl = "num_" + key.GetHashCode(); + GUI.SetNextControlName(ctrl); + bool focused = GUI.GetNameOfFocusedControl() == ctrl; + string live = isInt ? Mathf.RoundToInt(result).ToString() : result.ToString("0.###"); + string shownText = focused && TextBuffer.TryGetValue(key, out string buf) ? buf : live; + string typed = GUILayout.TextField(shownText, GUILayout.Width(70f)); + if (focused) { + TextBuffer[key] = typed; + if (EnterPressed() && float.TryParse(typed, out float parsed)) { result = parsed; } + } else if (TextBuffer.TryGetValue(key, out string pendingText)) { + if (float.TryParse(pendingText, out float p)) { result = p; } + TextBuffer.Remove(key); + } + + GUILayout.Label($"[{(isInt ? min.ToString() : min.ToString("0.#"))} - {(isInt ? max.ToString() : max.ToString("0.#"))}]", _dim, GUILayout.Width(90f)); + GUILayout.EndHorizontal(); + return result; + } + } + + // Collapses an ItemDefinition's ~10-20 ConfigEntries into a single expandable, grouped drawer. + internal static class ItemConfigDrawer { + private static readonly Dictionary Expanded = new Dictionary(); + // CSV recipe rows being edited in-memory; only written back to the ConfigEntry on "Apply". + private static readonly Dictionary> RecipeRows = new Dictionary>(); + private static int _order = 0; + + private static readonly KeyValuePair[] StatGroups = new KeyValuePair[] { + new KeyValuePair("Damage", new[] { + ItemStat.slash, ItemStat.slash_per_level, ItemStat.blunt, ItemStat.blunt_per_level, + ItemStat.pierce, ItemStat.pierce_per_level, ItemStat.fire, ItemStat.fire_per_level, + ItemStat.lightning, ItemStat.lightning_per_level, ItemStat.frost, ItemStat.frost_per_level, + ItemStat.poison, ItemStat.poison_per_level, ItemStat.spirit, ItemStat.spirit_per_level, + ItemStat.pickaxe, ItemStat.pickaxe_per_level, ItemStat.chop, ItemStat.chop_per_level + }), + new KeyValuePair("Combat", new[] { + ItemStat.attack_force, ItemStat.primary_attack_force_multiply, ItemStat.secondary_attack_force_multiply, + ItemStat.primary_attack_stamina, ItemStat.primary_attack_eitr, ItemStat.primary_attack_flat_health_cost, + ItemStat.primary_attack_percent_health_cost, ItemStat.primary_attack_health_returned, + ItemStat.primary_attack_damage_bonus_per_missing_hp, ItemStat.primary_attack_projectile_count, + ItemStat.secondary_attack_stamina, ItemStat.secondary_attack_eitr, ItemStat.secondary_attack_flat_health_cost, + ItemStat.secondary_attack_percent_health_cost, ItemStat.projectile_velocity, ItemStat.projectile_accuracy_max, + ItemStat.bow_draw_speed, ItemStat.crossbow_reload_speed, ItemStat.crossbow_reload_stamina_drain, + ItemStat.draw_stamina_drain + }), + new KeyValuePair("Defense", new[] { + ItemStat.block_armor, ItemStat.block_armor_per_level, ItemStat.parry, + ItemStat.block_force, ItemStat.block_force_per_level + }), + new KeyValuePair("Item", new[] { + ItemStat.durability, ItemStat.durability_per_level, ItemStat.max_item_level, + ItemStat.tool_level, ItemStat.movement_speed, ItemStat.amount + }), + }; + + // Hides the item's sub-entries and turns CraftableCfg into the single visible drawer row. + internal static void Attach(ItemDefinition itemdef) { + ConfigDrawHelpers.Hide(itemdef.StationLVLCfg); + ConfigDrawHelpers.Hide(itemdef.CraftAmountCfg); + ConfigDrawHelpers.Hide(itemdef.CraftedAtCfg); + ConfigDrawHelpers.Hide(itemdef.Recipe.RecipeConfig); + if (itemdef.ModifableStats != null) { + foreach (ItemStatConfig stat in itemdef.ModifableStats.Values) { + if (stat.Cfg != null) { ConfigDrawHelpers.Hide(stat.Cfg); } + if (stat.CfgInt != null) { ConfigDrawHelpers.Hide(stat.CfgInt); } + } + } + if (itemdef.DamageMods != null) { + foreach (HitCustomDamageMod mod in itemdef.DamageMods.Values) { + if (mod.DmgModCfg != null) { ConfigDrawHelpers.Hide(mod.DmgModCfg); } + } + } + + ConfigurationManagerAttributes anchor = ConfigDrawHelpers.GetAttributes(itemdef.CraftableCfg); + if (anchor != null) { + anchor.CustomDrawer = _ => Draw(itemdef); + anchor.HideSettingName = true; + anchor.HideDefaultButton = true; + anchor.Category = itemdef.Category.ToString(); + anchor.Order = _order--; + } + } + + private static void Draw(ItemDefinition itemdef) { + ConfigDrawHelpers.EnsureStyles(); + GUILayout.BeginVertical(GUI.skin.box); + + bool expanded = Expanded.TryGetValue(itemdef.DisplayName, out bool e) && e; + if (GUILayout.Button((expanded ? "▾ " : "▸ ") + itemdef.Name, ConfigDrawHelpers.HeaderButton)) { + expanded = !expanded; + Expanded[itemdef.DisplayName] = expanded; + } + + if (expanded) { + bool admin = SynchronizationManager.Instance == null || SynchronizationManager.Instance.PlayerIsAdmin; + if (!admin) { GUILayout.Label("Server-controlled – admin only.", ConfigDrawHelpers.Dim); } + bool prevEnabled = GUI.enabled; + GUI.enabled = admin; + + ConfigDrawHelpers.GroupHeader("Crafting"); + ConfigDrawHelpers.DrawBool("Craftable", itemdef.CraftableCfg); + ConfigDrawHelpers.DrawString("Crafted at", itemdef.CraftedAtCfg); + ConfigDrawHelpers.DrawInt("Station level", itemdef.StationLVLCfg); + ConfigDrawHelpers.DrawInt("Craft amount", itemdef.CraftAmountCfg); + + DrawRecipeEditor(itemdef); + + HashSet drawn = new HashSet(); + foreach (KeyValuePair group in StatGroups) { + List present = group.Value.Where(s => IsConfigurable(itemdef, s)).ToList(); + bool damageMods = group.Key == "Damage" && itemdef.DamageMods != null && itemdef.DamageMods.Count > 0; + if (present.Count == 0 && !damageMods) { continue; } + ConfigDrawHelpers.GroupHeader(group.Key); + foreach (ItemStat s in present) { DrawStat(itemdef, s); drawn.Add(s); } + if (damageMods) { + foreach (KeyValuePair mod in itemdef.DamageMods) { + if (mod.Value.Configurable == false || mod.Value.DmgModCfg == null) { continue; } + ConfigDrawHelpers.DrawChoice(mod.Key + " resistance", mod.Value.DmgModCfg); + } + } + } + + // Catch any configurable stat not covered by a group above. + if (itemdef.ModifableStats != null) { + List other = itemdef.ModifableStats.Keys.Where(s => !drawn.Contains(s) && IsConfigurable(itemdef, s)).ToList(); + if (other.Count > 0) { + ConfigDrawHelpers.GroupHeader("Other"); + foreach (ItemStat s in other) { DrawStat(itemdef, s); } + } + } + + GUI.enabled = prevEnabled; + } + + GUILayout.EndVertical(); + } + + private static bool IsConfigurable(ItemDefinition itemdef, ItemStat s) => + itemdef.ModifableStats != null && itemdef.ModifableStats.TryGetValue(s, out ItemStatConfig sc) && sc.Configurable; + + private static void DrawStat(ItemDefinition itemdef, ItemStat s) { + ItemStatConfig sc = itemdef.ModifableStats[s]; + string label = s.ToString().Replace('_', ' '); + if (sc.IsInt) { ConfigDrawHelpers.DrawInt(label, sc.CfgInt); } else { ConfigDrawHelpers.DrawFloat(label, sc.Cfg); } + } + + // Recipe shown as one row per resource: prefab (string) + amount (int) + per-level (int). + // Edits stay in-memory until "Apply", which re-serializes to the existing CSV format and writes it + // to the ConfigEntry, firing the existing SettingChanged -> ValidateRecipeConfig pipeline. + private static void DrawRecipeEditor(ItemDefinition itemdef) { + ConfigEntry cfg = itemdef.Recipe.RecipeConfig; + ConfigDrawHelpers.GroupHeader("Recipe"); + + if (!RecipeRows.TryGetValue(cfg, out List rows)) { + rows = ParseCsv(cfg.Value, 3); + RecipeRows[cfg] = rows; + } + + GUILayout.BeginHorizontal(); + GUILayout.Label("Resource (prefab)", GUILayout.Width(200f)); + GUILayout.Label("Amount", GUILayout.Width(60f)); + GUILayout.Label("Per level", GUILayout.Width(70f)); + GUILayout.EndHorizontal(); + + for (int i = 0; i < rows.Count; i++) { + string[] row = rows[i]; + GUILayout.BeginHorizontal(); + row[0] = GUILayout.TextField(row[0] ?? "", GUILayout.Width(200f)); + row[1] = DigitsOnly(GUILayout.TextField(row[1] ?? "", GUILayout.Width(60f))); + row[2] = DigitsOnly(GUILayout.TextField(row[2] ?? "", GUILayout.Width(70f))); + bool remove = GUILayout.Button("✕", GUILayout.Width(26f)); + GUILayout.EndHorizontal(); + if (remove) { rows.RemoveAt(i); i--; } + } + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Add resource", GUILayout.Width(120f))) { rows.Add(new[] { "", "1", "0" }); } + if (GUILayout.Button("Apply", GUILayout.Width(80f))) { cfg.Value = SerializeCsv(rows); } + if (GUILayout.Button("Reload", GUILayout.Width(80f))) { RecipeRows[cfg] = ParseCsv(cfg.Value, 3); } + GUILayout.EndHorizontal(); + GUILayout.Label("Applied: " + cfg.Value, ConfigDrawHelpers.Dim); + } + + internal static List ParseCsv(string csv, int cols) { + List rows = new List(); + foreach (string part in (csv ?? "").Split('|')) { + if (string.IsNullOrEmpty(part) || part.Trim().Length == 0) { continue; } + string[] seg = part.Split(','); + string[] row = new string[cols]; + for (int i = 0; i < cols; i++) { row[i] = i < seg.Length ? seg[i] : ""; } + rows.Add(row); + } + return rows; + } + + internal static string SerializeCsv(List rows) => + string.Join("|", rows.Where(r => !string.IsNullOrEmpty(r[0]) && r[0].Trim().Length > 0).Select(r => string.Join(",", r)).ToArray()); + + internal static string DigitsOnly(string s) => + new string((s ?? "").Where(ch => char.IsDigit(ch) || ch == '-').ToArray()); + } + + // Collapses a building piece's ConfigEntries into a single expandable, grouped drawer. + internal static class PieceConfigDrawer { + private static readonly Dictionary Expanded = new Dictionary(); + private static readonly Dictionary> CostRows = new Dictionary>(); + private static int _order = 0; + + // Hides the piece's sub-entries and turns Enabled into the single visible drawer row. + internal static void Attach(PieceLoader.BuildPiece jp) { + ConfigDrawHelpers.Hide(jp.Cfgs.RequiresWorkbench); + ConfigDrawHelpers.Hide(jp.Cfgs.Workbench); + ConfigDrawHelpers.Hide(jp.Cfgs.PieceCategory); + ConfigDrawHelpers.Hide(jp.Cfgs.PieceCost); + + ConfigurationManagerAttributes anchor = ConfigDrawHelpers.GetAttributes(jp.Cfgs.Enabled); + if (anchor != null) { + anchor.CustomDrawer = _ => Draw(jp); + anchor.HideSettingName = true; + anchor.HideDefaultButton = true; + anchor.Category = "Building Pieces"; + anchor.Order = _order--; + } + } + + private static void Draw(PieceLoader.BuildPiece jp) { + ConfigDrawHelpers.EnsureStyles(); + GUILayout.BeginVertical(GUI.skin.box); + + bool expanded = Expanded.TryGetValue(jp.Name, out bool e) && e; + if (GUILayout.Button((expanded ? "▾ " : "▸ ") + jp.Name, ConfigDrawHelpers.HeaderButton)) { + expanded = !expanded; + Expanded[jp.Name] = expanded; + } + + if (expanded) { + bool admin = SynchronizationManager.Instance == null || SynchronizationManager.Instance.PlayerIsAdmin; + if (!admin) { GUILayout.Label("Server-controlled – admin only.", ConfigDrawHelpers.Dim); } + bool prevEnabled = GUI.enabled; + GUI.enabled = admin; + + ConfigDrawHelpers.GroupHeader("Settings"); + ConfigDrawHelpers.DrawBool("Enabled", jp.Cfgs.Enabled); + ConfigDrawHelpers.DrawBool("Requires workbench", jp.Cfgs.RequiresWorkbench); + ConfigDrawHelpers.DrawString("Workbench", jp.Cfgs.Workbench); + ConfigDrawHelpers.DrawChoice("Piece category", jp.Cfgs.PieceCategory); + + DrawCostEditor(jp); + + GUI.enabled = prevEnabled; + } + + GUILayout.EndVertical(); + } + + // Building cost shown as one row per resource: prefab (string) + amount (int) + refundable (bool). + private static void DrawCostEditor(PieceLoader.BuildPiece jp) { + ConfigEntry cfg = jp.Cfgs.PieceCost; + ConfigDrawHelpers.GroupHeader("Building Cost"); + + if (!CostRows.TryGetValue(cfg, out List rows)) { + rows = ItemConfigDrawer.ParseCsv(cfg.Value, 3); + CostRows[cfg] = rows; + } + + GUILayout.BeginHorizontal(); + GUILayout.Label("Resource (prefab)", GUILayout.Width(200f)); + GUILayout.Label("Amount", GUILayout.Width(60f)); + GUILayout.Label("Refund", GUILayout.Width(90f)); + GUILayout.EndHorizontal(); + + for (int i = 0; i < rows.Count; i++) { + string[] row = rows[i]; + GUILayout.BeginHorizontal(); + row[0] = GUILayout.TextField(row[0] ?? "", GUILayout.Width(200f)); + row[1] = ItemConfigDrawer.DigitsOnly(GUILayout.TextField(row[1] ?? "", GUILayout.Width(60f))); + bool refund = (row[2] ?? "").Trim().ToLower() != "false"; + bool nr = GUILayout.Toggle(refund, refund ? " refundable" : " no refund", GUILayout.Width(110f)); + row[2] = nr ? "true" : "false"; + bool remove = GUILayout.Button("✕", GUILayout.Width(26f)); + GUILayout.EndHorizontal(); + if (remove) { rows.RemoveAt(i); i--; } + } + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Add resource", GUILayout.Width(120f))) { rows.Add(new[] { "", "1", "true" }); } + if (GUILayout.Button("Apply", GUILayout.Width(80f))) { cfg.Value = ItemConfigDrawer.SerializeCsv(rows); } + if (GUILayout.Button("Reload", GUILayout.Width(80f))) { CostRows[cfg] = ItemConfigDrawer.ParseCsv(cfg.Value, 3); } + GUILayout.EndHorizontal(); + GUILayout.Label("Applied: " + cfg.Value, ConfigDrawHelpers.Dim); + } + } +} diff --git a/Common/src/Modding/ItemBatchLoader.cs b/Common/src/Modding/ItemBatchLoader.cs new file mode 100644 index 000000000..5ac699bac --- /dev/null +++ b/Common/src/Modding/ItemBatchLoader.cs @@ -0,0 +1,698 @@ +using BepInEx.Configuration; +using Jotunn.Configs; +using Jotunn.Entities; +using Jotunn.Managers; +using Jotunn.Utils; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Common { + /// + /// Batch-registers config-driven items from an asset bundle and keeps them reconciled to their config + /// entries for the rest of the session (recipe, station, station level, craft amount, per-stat values + /// and damage modifiers), including across server config syncs and ObjectDB rebuilds. + /// + /// Usage: add every with , then call + /// once, after . + /// + /// State is static: one batch per mod assembly, which is all a shared-project consumer needs. + /// + public class ItemBatchLoader { + internal static List resourceDefinitions = new List(); + internal static AssetBundle Assets; + internal static Dictionary AddedItems = new Dictionary(); + internal static List ArcheryAmmoToAdd = new List(); + + /// + /// Bundle path for an item prefab; {0} is the category, {1} the prefab name. Set to null to look the + /// prefab up by its bare name (flat bundles). + /// + public static string PrefabPathFormat = "Assets/Custom/Weapons/{0}/{1}.prefab"; + + /// Bundle path for an item icon; {0} is the icon name. Null looks it up by bare name. + public static string IconPathFormat = "Assets/Custom/Icons/{0}.png"; + + /// + /// Whether Jotunn should fix up JVLmock_ references on the registered prefabs. Set false for prefabs + /// authored directly against the real game assets, which have no mocks to resolve. + /// + public static bool FixReferences = true; + + // Pending in-world item updates, drained once per frame so an entire burst of SettingChanged + // handlers (e.g. a full server config sync) costs a single Resources.FindObjectsOfTypeAll scan + // instead of one scan per changed setting. + private static readonly List>> pendingWorldUpdates = new List>>(); + private static bool worldUpdateScheduled = false; + + internal static readonly AcceptableValueList allowedModifiers = new AcceptableValueList(new string[] { + HitData.DamageModifier.Normal.ToString(), + HitData.DamageModifier.VeryWeak.ToString(), + HitData.DamageModifier.Weak.ToString(), + HitData.DamageModifier.Resistant.ToString(), + HitData.DamageModifier.VeryResistant.ToString(), + HitData.DamageModifier.Immune.ToString() + }); + + public bool AddDefinition(ItemDefinition itemdef) { + resourceDefinitions.Add(itemdef); + return true; + } + + /// + /// Binds every definition's config entries, registers the items with Jotunn, and hooks up the + /// change/sync handlers that keep them reconciled. + /// + /// Bundle to load prefabs and icons from; defaults to . + /// Config entries are ordered by bind order; reversing makes them appear in declaration order. + public bool BatchSetup(AssetBundle assetBundle = null, bool reverse_order = true) { + Assets = assetBundle ?? ModContext.AssetBundle; + if (Assets == null) { + ModLogger.LogError("No asset bundle available; items will not be registered."); + return false; + } + // Since configs are ordered by when they are connected this allows us to add things in the order they are defined. + if (reverse_order) { + resourceDefinitions.Reverse(); + } + WireConfigDefs(); + + bool on_server = ZNet.instance != null && Common.Utils.IsServer(); + + if (on_server == false) { + // This is not needed on the server + // The server does not actually do anything with prefabs, and is not responsible for modifying them + BatchAddItems(); + SetupOnChange(); + ItemManager.OnItemsRegistered += AddAmmoItemsToArcheryTarget; + // Re-apply config driven recipe values whenever the ObjectDB is (re)built. Jotunn re-adds the + // cached (local-config) recipes on every ObjectDB.Awake, so this reconciles them to the current + // (possibly server-synced) config values. Also re-apply when admin config arrives from the server. + ItemManager.OnItemsRegistered += ReapplyAllRecipeConfig; + SynchronizationManager.OnConfigurationSynchronized += OnModConfigsChanged; + } + + // Flush to disk + ModContext.SaveOnSet(true); + return true; + } + + private static bool WireConfigDefs() { + foreach (ItemDefinition itemdef in resourceDefinitions) { + // Build a compacted display name for reference, this primarily just needs spaces removed. + itemdef.DisplayName = string.Join("", itemdef.Name.Split(default(string[]), StringSplitOptions.RemoveEmptyEntries)); + // Blow up if adding a non-unique data control + AddedItems.Add(itemdef.DisplayName, itemdef.Prefab); + string section = $"{itemdef.Category} - {itemdef.Name}"; + itemdef.CraftableCfg = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-craftable", itemdef.Craftable, $"Enable/Disable the crafting recipe for {itemdef.Name}."); + itemdef.StationLVLCfg = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-stationRequiredLevel", itemdef.ReqStationlevel, $"Sets the required minimum crafting station level to craft {itemdef.Name}", true, 1, 4); + itemdef.CraftAmountCfg = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-craftAmount", itemdef.CraftAmount, $"Sets the amount of {itemdef.Name} crafted per recipe.", true, 1, 50); + itemdef.CraftedAtCfg = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-craftedAt", itemdef.CraftedAt, $"Sets the crafting station for {itemdef.Name}."); + // Setup the modifiable stats that this item has defined + foreach (KeyValuePair stat in itemdef.ModifableStats) { + if (stat.Value.Configurable == false) { continue; } + if (stat.Value.IsInt) { + stat.Value.CfgInt = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-{stat.Key}", (int)stat.Value.Default_value, $"Value for {stat.Key} on {itemdef.Name}", true, (int)stat.Value.Min, (int)stat.Value.Max); + } else { + stat.Value.Cfg = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-{stat.Key}", stat.Value.Default_value, $"Value for {stat.Key} on {itemdef.Name}", true, stat.Value.Min, stat.Value.Max); + } + } + // Set the damage modifiers for this item + if (itemdef.DamageMods != null) { + foreach (KeyValuePair dmgmod in itemdef.DamageMods) { + dmgmod.Value.DmgModCfg = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-{dmgmod.Key}-DamageModifier", dmgmod.Value.DamageModifier.ToString(), $"Damage modifier for {dmgmod.Key} on {itemdef.Name}", allowedModifiers, true); + } + } + + // Build the item recipe + itemdef.Recipe.RecipeConfig = ConfigBinder.BindServerConfig(section, $"{itemdef.DisplayName}-recipe", BuildStringRecipeFromItemDef(itemdef), $"Recipe for {itemdef.Name}. Should be in the format of Prefab,Amount,AmountPerLevel|Prefab,Amount,AmountPerLevel eg: Wood,12,2|Stone,2,0"); + if (ValidateRecipeConfig(itemdef) == false) { + BuildRecipeReqsFromDefault(itemdef); + } + + // Collapse this item's entries into a single grouped custom drawer to keep the in-game + // Configuration Manager responsive (one visible row per item instead of ~10-20). + ItemConfigDrawer.Attach(itemdef); + } + return true; + } + + // TODO: Change batch onchange actions to pass to a queue and execute queue from a couroutine. + private bool SetupOnChange() { + foreach (ItemDefinition itemdef in resourceDefinitions) { + // Need to have config onchange settings available for items which are not enabled to ensure that we can enable them when joining a remote server with different items enabled + // Craftable config toggle + itemdef.CraftableCfg.SettingChanged += (_, __) => { + ConfigChangeDebouncer.Schedule(itemdef.CraftableCfg, () => EnableDisableItemInDB(itemdef, itemdef.CraftableCfg.Value)); + }; + // Station level config + itemdef.StationLVLCfg.SettingChanged += (_, __) => { + ConfigChangeDebouncer.Schedule(itemdef.StationLVLCfg, () => ModifyItemRecipeLevel(itemdef, itemdef.StationLVLCfg.Value)); + }; + // Modify where the item is crafted + itemdef.CraftedAtCfg.SettingChanged += (_, __) => { + ConfigChangeDebouncer.Schedule(itemdef.CraftedAtCfg, () => ModifyItemRecipeCraftedAt(itemdef)); + }; + // Modify how many of the item are crafted per recipe + itemdef.CraftAmountCfg.SettingChanged += (_, __) => { + ConfigChangeDebouncer.Schedule(itemdef.CraftAmountCfg, () => ModifyItemRecipeCraftAmount(itemdef, itemdef.CraftAmountCfg.Value)); + }; + + // All of the configurable stat variables + foreach (KeyValuePair stat in itemdef.ModifableStats) { + if (stat.Value.Configurable == false) { continue; } + object statKey = stat.Value.IsInt ? (object)stat.Value.CfgInt : stat.Value.Cfg; + void UpdateFromConfig(object sender, EventArgs args) { + ConfigChangeDebouncer.Schedule(statKey, () => { + if (ZNet.instance == null || ZNet.instance.enabled == false) { return; } + stat.Value.Default_value = stat.Value.IsInt ? stat.Value.CfgInt.Value : stat.Value.Cfg.Value; + // Update player items + UpdateItemInPlayerInventory(itemdef.Prefab, (ItemDrop.ItemData item) => { ItemDataConfigModifier(stat.Key, stat.Value.Default_value, item); }); + // Update in world items, batched into a single scan to prevent lag spikes (e.g. on server config sync). + EnqueueWorldUpdate(itemdef.Prefab, (ItemDrop.ItemData item) => { ItemDataConfigModifier(stat.Key, stat.Value.Default_value, item); }); + }); + } + + if (stat.Value.IsInt) { + stat.Value.CfgInt.SettingChanged += UpdateFromConfig; + } else { + stat.Value.Cfg.SettingChanged += UpdateFromConfig; + } + } + + // Modify the recipe in the object DB + itemdef.Recipe.RecipeConfig.SettingChanged += (sender, args) => { + ConfigChangeDebouncer.Schedule(itemdef.Recipe.RecipeConfig, () => { + if (ValidateRecipeConfig(itemdef)) { + ModifyItemRecipeInODB(itemdef); + } + }); + }; + + //Modify the damage modifiers + if (itemdef.DamageMods == null) { continue; } + foreach (KeyValuePair dmgmod in itemdef.DamageMods) { + dmgmod.Value.DmgModCfg.SettingChanged += (_, __) => { + ConfigChangeDebouncer.Schedule(dmgmod.Value.DmgModCfg, () => { + if (ZNet.instance == null || ZNet.instance.enabled == false) { return; } + HitData.DamageModifier modifier = (HitData.DamageModifier)Enum.Parse(typeof(HitData.DamageModifier), dmgmod.Value.DmgModCfg.Value); + // Update player items + UpdateItemInPlayerInventory(itemdef.Prefab, (ItemDrop.ItemData item) => { SetItemDamageModifier(modifier, dmgmod.Key, item); }); + // Update world items, batched into a single scan to prevent lag spikes (e.g. on server config sync). + EnqueueWorldUpdate(itemdef.Prefab, (ItemDrop.ItemData item) => { SetItemDamageModifier(modifier, dmgmod.Key, item); }); + }); + }; + } + } + return true; + } + + // Idempotently reconciles every item recipe in the live ObjectDB to the current config values. + // Safe to call repeatedly and from multiple lifecycle events; self guards when no ObjectDB exists. + private static void ReapplyAllRecipeConfig() { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return; } + foreach (ItemDefinition itemdef in resourceDefinitions) { + // Make sure the recipe is present before we try to modify it. A prior ObjectDB.CopyOtherDB + // (server join) can drop our custom recipes, so re-add disabled items too - their config is + // applied here and EnableDisableItemInDB sets the disabled flag last. + EnsureRecipeInDB(itemdef); + if (ValidateRecipeConfig(itemdef)) { ModifyItemRecipeInODB(itemdef); } + ModifyItemRecipeLevel(itemdef, itemdef.StationLVLCfg.Value); + ModifyItemRecipeCraftedAt(itemdef); + ModifyItemRecipeCraftAmount(itemdef, itemdef.CraftAmountCfg.Value); + EnableDisableItemInDB(itemdef, itemdef.CraftableCfg.Value); + } + // Refresh an open crafting panel so changed recipes/amounts/enabled state are reflected immediately. + if (Player.m_localPlayer != null) { Player.m_localPlayer.UpdateKnownRecipesList(); } + } + + // Fires when admin (server) config is synchronized to this client. Only re-apply when our plugin's + // config was part of the sync payload to avoid needless work when other mods sync. + private static void OnModConfigsChanged(object sender, ConfigurationSynchronizationEventArgs e) { + if (e.UpdatedPluginGUIDs != null && e.UpdatedPluginGUIDs.Count > 0 && !e.UpdatedPluginGUIDs.Contains(ModContext.PluginGuid)) { + return; + } + ReapplyAllRecipeConfig(); + } + + // Resolves a bundle asset, preferring the configured path convention and falling back to the bare + // name so flat bundles (Jam, AdvancedPortals, EpicLoot) work with the same loader. + private static T LoadBundleAsset(string pathFormat, string bareName, params object[] formatArgs) where T : UnityEngine.Object { + if (!string.IsNullOrEmpty(pathFormat)) { + T viaPath = Assets.LoadAsset(string.Format(pathFormat, formatArgs)); + if (viaPath != null) { return viaPath; } + } + return Assets.LoadAsset(bareName); + } + + private static bool BatchAddItems() { + foreach (ItemDefinition itemdef in resourceDefinitions) { + GameObject ItemPrefab = LoadBundleAsset(PrefabPathFormat, itemdef.Prefab, itemdef.Category, itemdef.Prefab); + if (ItemPrefab == null) { + ModLogger.LogError($"Could not find prefab '{itemdef.Prefab}' for {itemdef.Name} in the asset bundle; skipping."); + continue; + } + // Icon is optional: a prefab authored with its own icon leaves it unset and Jotunn keeps + // whatever the ItemDrop already carries. + Sprite ItemSprite = string.IsNullOrEmpty(itemdef.Icon) + ? null + : LoadBundleAsset(IconPathFormat, itemdef.Icon, itemdef.Icon); + ItemDrop ItemD = ItemPrefab.GetComponent(); + // Modify this items stats + foreach (KeyValuePair modstat in itemdef.ModifableStats) { + if (modstat.Value.Configurable == false) { + ItemDataConfigModifier(modstat.Key, modstat.Value.Default_value, ItemD.m_itemData); + } else { + if (modstat.Value.IsInt) { + ItemDataConfigModifier(modstat.Key, modstat.Value.CfgInt.Value, ItemD.m_itemData); + } else { + ItemDataConfigModifier(modstat.Key, modstat.Value.Cfg.Value, ItemD.m_itemData); + } + } + } + // Modify this items resistances + if (itemdef.DamageMods != null) { + foreach (KeyValuePair dmgmod in itemdef.DamageMods) { + if (dmgmod.Value.Configurable == false || dmgmod.Value.DmgModCfg == null) { continue; } + HitData.DamageModifier modifier = (HitData.DamageModifier)Enum.Parse(typeof(HitData.DamageModifier), dmgmod.Value.DmgModCfg.Value); + SetItemDamageModifier(modifier, dmgmod.Key, ItemD.m_itemData); + } + } + ItemConfig itemcfg = new ItemConfig() { + Amount = itemdef.CraftAmountCfg.Value, + CraftingStation = $"{itemdef.CraftedAtCfg.Value}", + MinStationLevel = itemdef.StationLVLCfg.Value, + // Always register as enabled so the recipe is added to and retained in the ObjectDB (a + // recipe registered disabled never gets cached/retained). The real craftable state is + // applied immediately after by ReapplyAllRecipeConfig -> EnableDisableItemInDB, so a + // disabled item still lives in the DB (m_enabled=false), stays modifiable, and re-enables + // correctly - including after a server ObjectDB copy replaces the recipe list. + Enabled = true, + Icons = ItemSprite != null ? new[] { ItemSprite } : null, + Requirements = itemdef.Recipe.RecipeReqs.ToArray() + }; + ItemManager.Instance.AddItem(new CustomItem(ItemPrefab, FixReferences, itemcfg)); + + // This item needs to be included as a returnable arrow/bolt + if (itemdef.Category == ItemCategory.Arrows) { + ArcheryAmmoToAdd.Add(itemdef.Prefab); + } + } + return true; + } + + private static void ItemDataConfigModifier(ItemStat target_attribute, float updatedValue, ItemDrop.ItemData itemData) { + if (itemData == null) { return; } + switch (target_attribute) { + // Standard Dmg types + case ItemStat.slash: + itemData.m_shared.m_damages.m_slash = updatedValue; + break; + case ItemStat.slash_per_level: + itemData.m_shared.m_damagesPerLevel.m_slash = updatedValue; + break; + case ItemStat.blunt: + itemData.m_shared.m_damages.m_blunt = updatedValue; + break; + case ItemStat.blunt_per_level: + itemData.m_shared.m_damagesPerLevel.m_blunt = updatedValue; + break; + case ItemStat.pierce: + itemData.m_shared.m_damages.m_pierce = updatedValue; + break; + case ItemStat.pierce_per_level: + itemData.m_shared.m_damagesPerLevel.m_pierce = updatedValue; + break; + // Special Damage Types + case ItemStat.pickaxe: + itemData.m_shared.m_damages.m_pickaxe = updatedValue; + break; + case ItemStat.pickaxe_per_level: + itemData.m_shared.m_damagesPerLevel.m_pickaxe = updatedValue; + break; + case ItemStat.chop: + itemData.m_shared.m_damages.m_chop = updatedValue; + break; + case ItemStat.chop_per_level: + itemData.m_shared.m_damagesPerLevel.m_chop = updatedValue; + break; + case ItemStat.attack_force: + itemData.m_shared.m_attackForce = updatedValue; + break; + case ItemStat.secondary_attack_force_multiply: + itemData.m_shared.m_secondaryAttack.m_forceMultiplier = updatedValue; + break; + case ItemStat.primary_attack_force_multiply: + itemData.m_shared.m_attack.m_forceMultiplier = updatedValue; + break; + // Elemental Damage Types + case ItemStat.fire: + itemData.m_shared.m_damages.m_fire = updatedValue; + break; + case ItemStat.fire_per_level: + itemData.m_shared.m_damagesPerLevel.m_fire = updatedValue; + break; + case ItemStat.lightning: + itemData.m_shared.m_damages.m_lightning = updatedValue; + break; + case ItemStat.lightning_per_level: + itemData.m_shared.m_damagesPerLevel.m_lightning = updatedValue; + break; + case ItemStat.frost: + itemData.m_shared.m_damages.m_frost = updatedValue; + break; + case ItemStat.frost_per_level: + itemData.m_shared.m_damagesPerLevel.m_frost = updatedValue; + break; + case ItemStat.poison: + itemData.m_shared.m_damages.m_poison = updatedValue; + break; + case ItemStat.poison_per_level: + itemData.m_shared.m_damagesPerLevel.m_poison = updatedValue; + break; + case ItemStat.spirit: + itemData.m_shared.m_damages.m_spirit = updatedValue; + break; + case ItemStat.spirit_per_level: + itemData.m_shared.m_damagesPerLevel.m_spirit = updatedValue; + break; + // Block and parry + case ItemStat.block_armor: + itemData.m_shared.m_blockPower = updatedValue; + break; + case ItemStat.block_armor_per_level: + itemData.m_shared.m_blockPowerPerLevel = updatedValue; + break; + case ItemStat.parry: + itemData.m_shared.m_timedBlockBonus = updatedValue; + break; + case ItemStat.block_force: + itemData.m_shared.m_deflectionForce = updatedValue; + break; + case ItemStat.block_force_per_level: + itemData.m_shared.m_deflectionForcePerLevel = updatedValue; + break; + // Costs for attack types + case ItemStat.primary_attack_stamina: + itemData.m_shared.m_attack.m_attackStamina = updatedValue; + break; + case ItemStat.primary_attack_eitr: + itemData.m_shared.m_attack.m_attackEitr = updatedValue; + break; + case ItemStat.primary_attack_flat_health_cost: + itemData.m_shared.m_attack.m_attackHealth = updatedValue; + break; + case ItemStat.primary_attack_percent_health_cost: + itemData.m_shared.m_attack.m_attackHealthPercentage = updatedValue; + break; + case ItemStat.primary_attack_health_returned: + itemData.m_shared.m_attack.m_attackHealthReturnHit = updatedValue; + break; + case ItemStat.primary_attack_damage_bonus_per_missing_hp: + itemData.m_shared.m_attack.m_damageMultiplierPerMissingHP = updatedValue; + break; + case ItemStat.primary_attack_projectile_count: + itemData.m_shared.m_attack.m_projectiles = (int)updatedValue; + break; + case ItemStat.secondary_attack_stamina: + itemData.m_shared.m_secondaryAttack.m_attackStamina = updatedValue; + break; + case ItemStat.secondary_attack_eitr: + itemData.m_shared.m_secondaryAttack.m_attackEitr = updatedValue; + break; + case ItemStat.secondary_attack_flat_health_cost: + itemData.m_shared.m_secondaryAttack.m_attackHealth = updatedValue; + break; + case ItemStat.secondary_attack_percent_health_cost: + itemData.m_shared.m_secondaryAttack.m_attackHealthPercentage = updatedValue; + break; + // Speed Modifiers + case ItemStat.movement_speed: + itemData.m_shared.m_movementModifier = updatedValue; + break; + case ItemStat.bow_draw_speed: + itemData.m_shared.m_attack.m_drawDurationMin = updatedValue; + break; + case ItemStat.crossbow_reload_speed: + itemData.m_shared.m_attack.m_reloadTime = updatedValue; + break; + case ItemStat.crossbow_reload_stamina_drain: + itemData.m_shared.m_attack.m_reloadStaminaDrain = updatedValue; + break; + case ItemStat.draw_stamina_drain: + itemData.m_shared.m_attack.m_drawStaminaDrain = updatedValue; + break; + case ItemStat.projectile_velocity: + itemData.m_shared.m_attack.m_projectileVel = updatedValue; + break; + case ItemStat.projectile_accuracy_max: + itemData.m_shared.m_attack.m_projectileAccuracy = (100f - updatedValue); + break; + // Item Modifiers + case ItemStat.durability: + itemData.m_shared.m_maxDurability = updatedValue; + break; + case ItemStat.durability_per_level: + itemData.m_shared.m_durabilityPerLevel = updatedValue; + break; + case ItemStat.max_item_level: + itemData.m_shared.m_maxQuality = (int)updatedValue; + break; + // 'amount' is the inventory stack size; it is grouped with the other item modifiers in the + // config drawer and previously fell through to the "unknown stat" warning below. + case ItemStat.amount: + itemData.m_shared.m_maxStackSize = (int)updatedValue; + break; + case ItemStat.tool_level: + itemData.m_shared.m_toolTier = (int)updatedValue; + break; + default: + ModLogger.LogWarning($"Unknown item stat {target_attribute} for {itemData.m_shared.m_name}"); + break; + } + } + + private static bool ValidateRecipeConfig(ItemDefinition itemdef) { + List requirements = new List(); + try { + string[] recipeConfig = itemdef.Recipe.RecipeConfig.Value.Split('|'); + foreach (string ingredient in recipeConfig) { + string[] ingredientConfig = ingredient.Split(','); + if (ingredientConfig.Length == 1) { + // This is the first run or deleted config entry scenario + return false; + } + if (ingredientConfig.Length != 3) { + ModLogger.LogWarning($"Invalid ({itemdef.Name}) recipe config detected: {ingredient}. Needs three entries eg: Wood,1,1"); + return false; + } + requirements.Add(new RequirementConfig { Item = ingredientConfig[0], Amount = int.Parse(ingredientConfig[1]), AmountPerLevel = int.Parse(ingredientConfig[2]) }); + } + // Only happens if the recipe is valid + itemdef.Recipe.RecipeReqs = requirements; + return true; + } catch { + ModLogger.LogWarning($"Recipe is Invalid. Should have the format of Wood,1,1|Stone,2,0 - Prefab,cost,upgrade."); + return false; + } + } + + private static void BuildRecipeReqsFromDefault(ItemDefinition itemdef) { + List requirements = new List(); + foreach (RecipeIngredient recipeIng in itemdef.Recipe.RecipeItems) { + requirements.Add(new RequirementConfig { Item = recipeIng.Prefab, Amount = recipeIng.Amount, AmountPerLevel = recipeIng.UpgradeCost }); + } + itemdef.Recipe.RecipeReqs = requirements; + } + + private static string BuildStringRecipeFromItemDef(ItemDefinition itemdef) { + List recipe = new List(); + foreach (RecipeIngredient req in itemdef.Recipe.RecipeItems) { + recipe.Add($"{req.Prefab},{req.Amount},{req.UpgradeCost}"); + } + return string.Join("|", recipe.ToArray()); + } + + private static bool ModifyItemRecipeCraftedAt(ItemDefinition itemdef) { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return false; } + + int index = GetRecipeIndexByPrefab(itemdef.Prefab); + if (index == -1) { + ModLogger.LogWarning($"Recipe of {itemdef.Prefab} not found in ObjectDB, recipe will not be modified."); + return false; + } + CraftingStation craftable_at = PrefabManager.Instance.GetPrefab(itemdef.CraftedAtCfg.Value)?.GetComponent(); + if (craftable_at == null) { + ModLogger.LogWarning($"Crafting Station {itemdef.CraftedAtCfg.Value} prefab not found, or does not have a crafting station componet."); + return false; + } + ObjectDB.instance.m_recipes[index].m_craftingStation = craftable_at; + // repair station should likely be split out into a seperate config + ObjectDB.instance.m_recipes[index].m_repairStation = craftable_at; + return true; + } + + private static void ModifyItemRecipeInODB(ItemDefinition itemdef) { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return; } + + int recipe_index = GetRecipeIndexByPrefab(itemdef.Prefab); + if (recipe_index == -1) { + ModLogger.LogWarning($"Recipe of {itemdef.Prefab} not found in ObjectDB, Recipe will not be modified."); + return; + } + Recipe current_recipe = ObjectDB.instance.m_recipes[recipe_index]; + Recipe newRecipe = current_recipe; + List newRequirements = new List(); + foreach (RequirementConfig req in itemdef.Recipe.RecipeReqs) { + GameObject resgo = ObjectDB.instance.GetItemPrefab(req.Item); + if (resgo == null) { + ModLogger.LogWarning($"Recipe for {itemdef.Prefab} has an invalid requirement {req.Item}."); + return; + } + newRequirements.Add(new Piece.Requirement { m_resItem = resgo.GetComponent(), m_amount = req.Amount, m_amountPerLevel = req.AmountPerLevel }); + } + newRecipe.m_resources = newRequirements.ToArray(); + + int index = ObjectDB.instance.m_recipes.IndexOf(current_recipe); + if (index > -1) { + ObjectDB.instance.m_recipes[index] = newRecipe; + itemdef.Recipe.ResolvedRecipe = newRecipe; + } else { + ModLogger.LogWarning($"Recipe {current_recipe.name} not found in ObjectDB."); + } + } + + // Re-add itemdef's cached recipe to the live ObjectDB if a prior ObjectDB.CopyOtherDB (server join) + // replaced m_recipes and dropped it. Keeps even disabled items present so their recipe can still be + // modified and correctly re-enabled later. + private static void EnsureRecipeInDB(ItemDefinition itemdef) { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return; } + if (GetRecipeIndexByPrefab(itemdef.Prefab) != -1) { return; } + if (itemdef.Recipe.ResolvedRecipe != null) { + ObjectDB.instance.m_recipes.Add(itemdef.Recipe.ResolvedRecipe); + } + } + + private static void EnableDisableItemInDB(ItemDefinition itemdef, bool enable) { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return; } + + int index = GetRecipeIndexByPrefab(itemdef.Prefab); + if (index == -1) { + // Recipe was dropped (e.g. server ObjectDB copy). Re-add our cached recipe so a disabled + // item still lives in the DB and can be modified / re-enabled later. + if (itemdef.Recipe.ResolvedRecipe != null) { + itemdef.Recipe.ResolvedRecipe.m_enabled = enable; + ObjectDB.instance.m_recipes.Add(itemdef.Recipe.ResolvedRecipe); + } else { + ModLogger.LogWarning($"Recipe of {itemdef.Prefab} not found in ObjectDB and no cached recipe to re-add."); + } + return; + } + // recipe exists in the ODB + ObjectDB.instance.m_recipes[index].m_enabled = enable; + itemdef.Recipe.ResolvedRecipe = ObjectDB.instance.m_recipes[index]; + } + + private static void ModifyItemRecipeLevel(ItemDefinition itemdef, int level) { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return; } + int index = GetRecipeIndexByPrefab(itemdef.Prefab); + if (index == -1) { + ModLogger.LogWarning($"Recipe of {itemdef.Prefab} not found in ObjectDB, required level will not be modified."); + return; + } + ObjectDB.instance.m_recipes[index].m_minStationLevel = level; + // Update the stored recipe so if we use it to target things again it will still be accurate + itemdef.Recipe.ResolvedRecipe = ObjectDB.instance.m_recipes[index]; + } + + private static void ModifyItemRecipeCraftAmount(ItemDefinition itemdef, int amount) { + if (ObjectDB.instance == null || ObjectDB.instance.m_recipes == null) { return; } + int index = GetRecipeIndexByPrefab(itemdef.Prefab); + if (index == -1) { + ModLogger.LogWarning($"Recipe of {itemdef.Prefab} not found in ObjectDB, craft amount will not be modified."); + return; + } + ObjectDB.instance.m_recipes[index].m_amount = amount; + // Update the stored recipe so if we use it to target things again it will still be accurate + itemdef.Recipe.ResolvedRecipe = ObjectDB.instance.m_recipes[index]; + } + + private static int GetRecipeIndexByPrefab(string prefab) { + return ObjectDB.instance.m_recipes.FindIndex(m => m.m_item != null && m.m_item.name == prefab); + } + + private static void SetItemDamageModifier(HitData.DamageModifier modifier, HitData.DamageType type, ItemDrop.ItemData itemData) { + List temp = itemData.m_shared.m_damageModifiers.Where(entry => entry.m_type != type).ToList(); + if (temp.Count == 0) { + itemData.m_shared.m_damageModifiers.Clear(); + itemData.m_shared.m_damageModifiers.Add(new HitData.DamageModPair() { m_modifier = modifier, m_type = type }); + } else { + temp.Add(new HitData.DamageModPair() { m_modifier = modifier, m_type = type }); + itemData.m_shared.m_damageModifiers = temp; + } + } + + private static void UpdateItemInPlayerInventory(string prefab, Action callback) { + if (Player.m_localPlayer == null) { return; } + foreach (ItemDrop.ItemData user_item in Player.m_localPlayer.m_inventory.GetAllItems()) { + if (user_item == null || user_item.m_dropPrefab == null) { continue; } + if (user_item.m_dropPrefab.name != prefab) { continue; } + callback(user_item); + } + } + + // Queues an in-world item update. All updates enqueued within a frame are applied together in a single + // scan (see DrainWorldUpdates), collapsing N Resources.FindObjectsOfTypeAll scans - one per changed + // setting during a config sync - into one. + private static void EnqueueWorldUpdate(string prefab, Action callback) { + // Skip during game shutdown: the ThreadingHelper's MonoBehaviour is destroyed (StartCoroutine + // would throw) and there are no in-world items left to update. Unity's '==' treats it as null. + BepInEx.ThreadingHelper host = BepInEx.ThreadingHelper.Instance; + if (host == null) { return; } + pendingWorldUpdates.Add(new KeyValuePair>(prefab, callback)); + if (worldUpdateScheduled) { return; } + worldUpdateScheduled = true; + host.StartCoroutine(DrainWorldUpdates()); + } + + // Applies all queued in-world item updates using a single Resources.FindObjectsOfTypeAll scan. + private static IEnumerator DrainWorldUpdates() { + // Wait a frame so the full burst of SettingChanged handlers (e.g. an entire config sync) enqueues first. + yield return null; + try { + if (pendingWorldUpdates.Count > 0) { + foreach (GameObject go in Resources.FindObjectsOfTypeAll()) { + if (go == null) { continue; } + if (!go.TryGetComponent(out ItemDrop id)) { continue; } + foreach (KeyValuePair> update in pendingWorldUpdates) { + if (go.name.StartsWith(update.Key)) { + update.Value(id.m_itemData); + } + } + } + } + } finally { + pendingWorldUpdates.Clear(); + worldUpdateScheduled = false; + } + } + + private static void AddAmmoItemsToArcheryTarget() { + if (ArcheryAmmoToAdd.Count == 0) { return; } + GameObject archerTarget = PrefabManager.Instance.GetPrefab("piece_ArcheryTarget"); + if (archerTarget == null) { return; } + ArcheryTarget ArcherAmmoManger = archerTarget.GetComponentInChildren(true); + if (ArcherAmmoManger == null) { return; } + + foreach (string ammoPrefab in ArcheryAmmoToAdd) { + ModLogger.LogDebug($"Adding {ammoPrefab} to Archery Target Ammo Return."); + ItemDrop ammoID = PrefabManager.Instance.GetPrefab(ammoPrefab)?.GetComponent(); + if (ammoID != null && ArcherAmmoManger.m_returnAmmo.Contains(ammoID) == false) { + ArcherAmmoManger.m_returnAmmo.Add(ammoID); + } + } + } + } +} diff --git a/Common/src/Modding/ItemDefinition.cs b/Common/src/Modding/ItemDefinition.cs new file mode 100644 index 000000000..ac954c2a6 --- /dev/null +++ b/Common/src/Modding/ItemDefinition.cs @@ -0,0 +1,141 @@ +using BepInEx.Configuration; +using Jotunn.Configs; +using System.Collections.Generic; + +namespace Common { + /// Item fields that knows how to expose as config entries. + public enum ItemStat { + slash, + slash_per_level, + blunt, + blunt_per_level, + pierce, + pierce_per_level, + pickaxe, + pickaxe_per_level, + chop, + chop_per_level, + attack_force, + fire, + fire_per_level, + lightning, + lightning_per_level, + frost, + frost_per_level, + poison, + poison_per_level, + spirit, + spirit_per_level, + block_armor, + block_armor_per_level, + parry, + block_force, + block_force_per_level, + primary_attack_stamina, + primary_attack_eitr, + primary_attack_flat_health_cost, + primary_attack_percent_health_cost, + primary_attack_health_returned, + primary_attack_damage_bonus_per_missing_hp, + primary_attack_projectile_count, + primary_attack_force_multiply, + secondary_attack_stamina, + secondary_attack_eitr, + secondary_attack_force_multiply, + secondary_attack_flat_health_cost, + secondary_attack_percent_health_cost, + movement_speed, + bow_draw_speed, + crossbow_reload_speed, + crossbow_reload_stamina_drain, + draw_stamina_drain, + projectile_velocity, + projectile_accuracy_max, + durability, + durability_per_level, + max_item_level, + amount, + tool_level + } + + /// + /// Groups items in the config file and, by default, in the asset-bundle prefab path. + /// + public enum ItemCategory { + Arrows, + Atgeirs, + Axes, + Hammers, + Shields, + Swords, + Bows, + Spears, + Knives, + Maces, + Fists, + Pickaxes, + Magics, + /// Catch-all for non-weapon craftables (jewellery, belts, trinkets). + Misc + } + + /// + /// Authored definition of one config-driven item. Everything above the "Populated at runtime" line is + /// what a mod fills in; the ConfigEntry handles are wired up by . + /// + public class ItemDefinition { + // Metadata + public string Name { get; set; } + public string DisplayName { get; set; } + public ItemCategory Category { get; set; } + public string Prefab { get; set; } + public string Icon { get; set; } + + // Authored defaults, each exposed as a config entry + public string CraftedAt { get; set; } + public bool Craftable { get; set; } = true; + public int ReqStationlevel { get; set; } + public int CraftAmount { get; set; } + public Dictionary ModifableStats { get; set; } = new Dictionary(); + public Dictionary DamageMods { get; set; } + public RecipeDefinition Recipe { get; set; } + + // -- Populated at runtime by ItemBatchLoader ------------------------------------------------ + public ConfigEntry CraftedAtCfg { get; set; } + public ConfigEntry CraftableCfg { get; set; } + public ConfigEntry StationLVLCfg { get; set; } + public ConfigEntry CraftAmountCfg { get; set; } + } + + public class HitCustomDamageMod { + public bool Configurable { get; set; } = true; + public HitData.DamageModifier DamageModifier { get; set; } + public ConfigEntry DmgModCfg { get; set; } + } + + public class ItemStatConfig { + public bool Configurable { get; set; } = true; + public bool IsInt { get; set; } = false; + public float Default_value { get; set; } + public float Min { get; set; } = 0f; + public float Max { get; set; } = 400f; + + // Only one of these is bound, selected by IsInt. + public ConfigEntry Cfg { get; set; } + public ConfigEntry CfgInt { get; set; } + } + + public class RecipeDefinition { + /// Raw CSV form: Prefab,Amount,AmountPerLevel|Prefab,Amount,AmountPerLevel. + public ConfigEntry RecipeConfig { get; set; } + public List RecipeItems { get; set; } + public List RecipeReqs { get; set; } + public Recipe ResolvedRecipe { get; set; } + } + + public class RecipeIngredient { + public string Prefab { get; set; } + public int Amount { get; set; } + public int UpgradeCost { get; set; } = 0; + } +} diff --git a/Common/src/Modding/ModContext.cs b/Common/src/Modding/ModContext.cs new file mode 100644 index 000000000..2d39fb0d1 --- /dev/null +++ b/Common/src/Modding/ModContext.cs @@ -0,0 +1,106 @@ +using BepInEx; +using BepInEx.Configuration; +using BepInEx.Logging; +using Jotunn.Entities; +using UnityEngine; + +namespace Common { + /// + /// Per-mod ambient state that the shared loaders in this folder read from. + /// + /// Common is a *shared project*: its source is compiled into each consuming mod assembly, so these + /// statics are per-mod and never shared between plugins. Each plugin calls + /// once from Awake, before anything else in Common is used. + /// + public static class ModContext { + /// The owning plugin's log source. Prefer over using this directly. + public static ManualLogSource Log { get; private set; } + + /// The owning plugin's config file. binds against this. + public static ConfigFile Cfg { get; private set; } + + /// The owning plugin's BepInPlugin GUID, used to namespace Jotunn registrations. + public static string PluginGuid { get; private set; } + + /// Subfolder under BepInEx/config that this mod owns (localizations, patch files, ...). + public static string ConfigFolder { get; private set; } + + /// The mod's asset bundle. Set here or passed explicitly to the loaders that need one. + public static AssetBundle AssetBundle { get; set; } + + /// The mod's Jotunn localization instance. + public static CustomLocalization Localization { get; set; } + + /// Gates the verbose logging in the shared loaders. Never null after . + public static ConfigEntry EnableDebugMode { get; private set; } + + /// Seconds waits before applying a settled config edit. + public static ConfigEntry ConfigApplyDelay { get; private set; } + + public static bool Initialized { get; private set; } + + /// True when debug logging is on. Safe to call before Initialize. + public static bool DebugEnabled => EnableDebugMode != null && EnableDebugMode.Value; + + /// + /// Wires the shared Common code to this plugin. Call once from Awake, before binding any other + /// config or invoking / / . + /// + /// The BepInEx plugin instance (supplies the config file and GUID). + /// The plugin's log source. BaseUnityPlugin.Logger is protected, so the plugin + /// has to hand it over; call this from inside the plugin and pass its own Logger. + /// Subfolder name under BepInEx/config owned by this mod. + /// Optional; may also be assigned later once the bundle is loaded. + /// Optional; defaults to the mod's Jotunn localization if left null. + /// Config section the two baseline entries are bound into. + public static void Initialize(BaseUnityPlugin plugin, ManualLogSource log, string configFolder, AssetBundle assetBundle = null, + CustomLocalization localization = null, string configSection = "Common") { + if (plugin == null) { throw new System.ArgumentNullException(nameof(plugin)); } + + Log = log; + Cfg = plugin.Config; + PluginGuid = plugin.Info?.Metadata?.GUID ?? configFolder; + ConfigFolder = configFolder; + if (assetBundle != null) { AssetBundle = assetBundle; } + if (localization != null) { Localization = localization; } + + Cfg.SaveOnConfigSet = true; + + EnableDebugMode = Cfg.Bind(configSection, "EnableDebugMode", false, + new ConfigDescription("Enables verbose debug logging for the shared item/piece loaders.", + null, + new ConfigurationManagerAttributes { IsAdvanced = true })); + EnableDebugMode.SettingChanged += ModLogger.OnDebugModeChanged; + + ConfigApplyDelay = ConfigBinder.BindServerConfig(configSection, "Config Apply Delay", 1f, + "Delay in seconds before a changed config entry is applied in-game. Coalesces a burst of rapid " + + "edits (typing, file reloads, server sync) into a single apply. Set to 0 to apply instantly.", + true, 0f, 10f); + + Initialized = true; + ModLogger.RefreshLevel(); + } + + /// + /// Points the shared debug-logging gate at a config entry the mod already owns, instead of the + /// baseline entry bound by . + /// + public static void UseDebugEntry(ConfigEntry entry) { + if (entry == null) { return; } + if (EnableDebugMode != null) { EnableDebugMode.SettingChanged -= ModLogger.OnDebugModeChanged; } + EnableDebugMode = entry; + EnableDebugMode.SettingChanged += ModLogger.OnDebugModeChanged; + ModLogger.RefreshLevel(); + } + + /// + /// Toggles write-through saving and flushes. The loaders turn this off while binding a batch of + /// entries and back on afterwards so startup doesn't write the config file once per entry. + /// + public static void SaveOnSet(bool enabled) { + if (Cfg == null) { return; } + Cfg.SaveOnConfigSet = enabled; + Cfg.Save(); + } + } +} diff --git a/Common/src/Modding/ModLocalization.cs b/Common/src/Modding/ModLocalization.cs new file mode 100644 index 000000000..8a360250f --- /dev/null +++ b/Common/src/Modding/ModLocalization.cs @@ -0,0 +1,96 @@ +using Jotunn.Entities; +using Jotunn.Managers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace Common { + /// + /// Loads every localization JSON embedded in the mod assembly, mirrors it to + /// BepInEx/config/<ConfigFolder>/Localizations/<Language>.json so players can edit it, and + /// registers the merged result with Jotunn. + /// + /// The on-disk copy wins for keys it already has; keys the mod newly ships are added, and keys the mod + /// no longer ships are pruned. Files should be plain JSON objects named for a language from + /// https://valheim-modding.github.io/Jotunn/data/localization/language-list.html + /// + public static class ModLocalization { + /// Resource-name fragment identifying localization files, and the config subfolder name. + public static string LocalizationFolder = "Localizations"; + + public static void AddLocalizations(Assembly assembly = null) { + if (assembly == null) { assembly = Assembly.GetExecutingAssembly(); } + + CustomLocalization localization = ModContext.Localization ?? LocalizationManager.Instance.GetLocalization(); + ModContext.Localization = localization; + + string translationFolder = Path.Combine(BepInEx.Paths.ConfigPath, ModContext.ConfigFolder ?? assembly.GetName().Name, LocalizationFolder); + Directory.CreateDirectory(translationFolder); + + foreach (string embeddedResource in assembly.GetManifestResourceNames()) { + if (embeddedResource.IndexOf(LocalizationFolder, StringComparison.OrdinalIgnoreCase) < 0) { continue; } + if (!embeddedResource.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { continue; } + + string language = LanguageFromResourceName(embeddedResource); + if (string.IsNullOrEmpty(language)) { + ModLogger.LogWarning($"Could not derive a language name from '{embeddedResource}', skipping."); + continue; + } + + // Comments are used in the shipped files but are not valid JSON, so strip them first. + string shipped = Regex.Replace(ReadEmbeddedResourceFile(embeddedResource, assembly), @"\/\/.*", ""); + string onDiskPath = Path.Combine(translationFolder, $"{language}.json"); + + string merged = shipped; + if (File.Exists(onDiskPath)) { + try { + Dictionary shippedKeys = SimpleJson.SimpleJson.DeserializeObject>(shipped); + Dictionary onDiskKeys = SimpleJson.SimpleJson.DeserializeObject>(File.ReadAllText(onDiskPath)); + ReconcileKeys(shippedKeys, onDiskKeys); + merged = SimpleJson.SimpleJson.SerializeObject(onDiskKeys); + } catch (Exception e) { + // A hand-edited file that no longer parses gets replaced with the shipped copy rather + // than silently leaving the player with a broken (or missing) translation. + ModLogger.LogWarning($"Could not merge '{onDiskPath}' ({e.Message}); replacing it with the shipped localization."); + merged = shipped; + } + } + + File.WriteAllText(onDiskPath, merged); + localization.AddJsonFile(language, merged); + ModLogger.LogDebug($"Added localization '{language}' from {embeddedResource}"); + } + } + + // Resource names look like "...json" but the folder nesting varies by + // mod, so take the segment before the .json extension rather than a fixed index. + private static string LanguageFromResourceName(string embeddedResource) { + string withoutExtension = embeddedResource.Substring(0, embeddedResource.Length - ".json".Length); + return withoutExtension.Split('.').LastOrDefault(); + } + + // Adds keys the mod newly ships and drops keys it no longer ships, leaving player edits intact. + private static void ReconcileKeys(Dictionary shipped, Dictionary onDisk) { + List staleKeys = onDisk.Keys.Where(key => !shipped.ContainsKey(key)).ToList(); + foreach (KeyValuePair entry in shipped) { + if (onDisk.ContainsKey(entry.Key)) { continue; } + ModLogger.LogDebug($"Adding missing localization key {entry.Key}"); + onDisk.Add(entry.Key, entry.Value); + } + if (staleKeys.Count > 0) { + ModLogger.LogDebug($"Removing extra keys {string.Join(",", staleKeys.ToArray())}."); + foreach (string key in staleKeys) { onDisk.Remove(key); } + } + } + + private static string ReadEmbeddedResourceFile(string filename, Assembly assembly) { + using (Stream stream = assembly.GetManifestResourceStream(filename)) + using (StreamReader reader = new StreamReader(stream)) { + return reader.ReadToEnd(); + } + } + } +} diff --git a/Common/src/Modding/ModLogger.cs b/Common/src/Modding/ModLogger.cs new file mode 100644 index 000000000..306a1c18d --- /dev/null +++ b/Common/src/Modding/ModLogger.cs @@ -0,0 +1,54 @@ +using BepInEx.Logging; +using System; + +namespace Common { + /// + /// Level-gated logging for the shared Common code, forwarding to the owning plugin's log source. + /// + /// Named ModLogger rather than Logger on purpose: Common.Logger would be ambiguous with + /// in the many mod files that do using Common;. + /// + public static class ModLogger { + public static LogLevel Level = LogLevel.Info; + + internal static void OnDebugModeChanged(object sender, EventArgs e) { + RefreshLevel(); + } + + /// Re-reads and adjusts the gate. + public static void RefreshLevel() { + Level = ModContext.DebugEnabled ? LogLevel.Debug : LogLevel.Info; + } + + public static void SetDebugLogging(bool state) { + Level = state ? LogLevel.Debug : LogLevel.Info; + } + + public static void LogDebug(string message) { + if (Level >= LogLevel.Debug) { Write(LogLevel.Info, "[DEBUG]" + message); } + } + + public static void LogInfo(string message) { + if (Level >= LogLevel.Info) { Write(LogLevel.Info, message); } + } + + public static void LogWarning(string message) { + if (Level >= LogLevel.Warning) { Write(LogLevel.Warning, message); } + } + + public static void LogError(string message) { + if (Level >= LogLevel.Error) { Write(LogLevel.Error, message); } + } + + // ModContext.Log is null until Initialize runs (and after the plugin is torn down). Falling back to + // the Unity log keeps early/late diagnostics visible instead of throwing an NRE inside a config handler. + private static void Write(LogLevel level, string message) { + ManualLogSource log = ModContext.Log; + if (log == null) { + UnityEngine.Debug.Log($"[Common][{level}] {message}"); + return; + } + log.Log(level, message); + } + } +} diff --git a/Common/src/Modding/PieceLoader.cs b/Common/src/Modding/PieceLoader.cs new file mode 100644 index 000000000..8f62b06d1 --- /dev/null +++ b/Common/src/Modding/PieceLoader.cs @@ -0,0 +1,338 @@ +using BepInEx.Configuration; +using Jotunn.Configs; +using Jotunn.Entities; +using Jotunn.Managers; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Common { + /// + /// Registers a build piece with Jotunn and exposes its workbench requirement, build category and + /// build cost as server-synced config entries that apply live, without a restart. + /// + /// Call once per piece after and after the + /// asset bundle is available. + /// + public static class PieceLoader { + public class LoadedGameObjects { + public GameObject Prefab { get; set; } + public Sprite Sprite { get; set; } + public GameObject ScenePrefab { get; set; } + /// The resolved prefab's Piece component. Everything applied at runtime goes through this. + public Piece ScenePiece { get; set; } + } + + public class PieceCost { + public string Prefab { get; set; } + public int Amount { get; set; } + public bool Refundable { get; set; } = true; + } + + public class PieceConfigs { + public ConfigEntry Enabled { get; set; } + public ConfigEntry RequiresWorkbench { get; set; } + public ConfigEntry Workbench { get; set; } + public ConfigEntry PieceCategory { get; set; } + public ConfigEntry PieceCost { get; set; } + public List UpdatedCost { get; set; } = new List(); + } + + /// Authored definition of one build piece. + public class BuildPiece { + /// Config section name for this piece. + public string Name { get; set; } + public bool Enabled { get; set; } = true; + /// Prefab name in the asset bundle (with or without a .prefab suffix). + public string Prefab { get; set; } + /// Optional icon name in the asset bundle. Null uses the prefab's own icon. + public string Sprite { get; set; } + public string Category { get; set; } = PieceCategories.Misc; + public string PieceTable { get; set; } = PieceTables.Hammer; + /// Crafting station prefab required to build. Ignored when RequiresWorkbench is false. + public string Workbench { get; set; } = "piece_workbench"; + public bool RequiresWorkbench { get; set; } = true; + public bool AllowedInDungeons { get; set; } = false; + public List PieceCost { get; set; } = new List(); + + // Populated by in-game related runtime objects + public LoadedGameObjects Objs { get; set; } + public PieceConfigs Cfgs { get; set; } + } + + private static readonly List BuildPieces = new List(); + private static bool PiecesReady = false; + + public static void Register(BuildPiece jbuildpiece, AssetBundle assetBundle = null) { + AssetBundle bundle = assetBundle ?? ModContext.AssetBundle; + if (bundle == null) { + ModLogger.LogError($"No asset bundle available; piece '{jbuildpiece.Name}' will not be registered."); + return; + } + + LoadedGameObjects LGos = new LoadedGameObjects(); + LGos.Prefab = LoadBundleAsset(bundle, jbuildpiece.Prefab, ".prefab"); + if (LGos.Prefab == null) { + ModLogger.LogError($"Could not find prefab '{jbuildpiece.Prefab}' in the asset bundle; piece '{jbuildpiece.Name}' will not be registered."); + return; + } + if (!string.IsNullOrEmpty(jbuildpiece.Sprite)) { + LGos.Sprite = LoadBundleAsset(bundle, jbuildpiece.Sprite, ".png"); + } + jbuildpiece.Objs = LGos; + jbuildpiece.Cfgs = new PieceConfigs(); + + InitialPieceSetup(jbuildpiece); + + BuildPieces.Add(jbuildpiece); + + void ResolveAndApplyScenePrefab() { + GameObject scenePrefab = ResolveScenePrefab(jbuildpiece.Prefab); + if (scenePrefab == null) { + ModLogger.LogWarning($"Could not find a scene prefab named '{jbuildpiece.Prefab}' with a Piece component after prefab registration; skipping in-place setup for {jbuildpiece.Name}."); + return; + } + jbuildpiece.Objs.ScenePrefab = scenePrefab; + jbuildpiece.Objs.ScenePiece = scenePrefab.GetComponent(); + PiecesReady = true; + // Bring the current config (default or server-synced) into effect a single time now that + // every mod prefab is resolvable. This also covers values that arrived early via config sync. + ApplyWorkbench(jbuildpiece); + ApplyCategory(jbuildpiece); + ApplyRecipe(jbuildpiece); + } + PrefabManager.OnPrefabsRegistered += ResolveAndApplyScenePrefab; + } + + // Finds the registered prefab to apply config to. Jotunn's PrefabManager is authoritative, so ask it + // first; the Resources scan is only a fallback for prefabs injected into ZNetScene by other means. + // + // The scan must filter on the Piece component: several loaded GameObjects can share a prefab's name + // (the bundle asset, Jotunn's registered clone, child objects), Resources.FindObjectsOfTypeAll returns + // them in no defined order, and picking one without a Piece made every Apply* below throw an NRE. + private static GameObject ResolveScenePrefab(string prefabName) { + GameObject registered = PrefabManager.Instance.GetPrefab(prefabName); + if (registered != null && registered.GetComponent() != null) { + return registered; + } + + List candidates = Resources.FindObjectsOfTypeAll() + .Where(obj => obj.name == prefabName && obj.GetComponent() != null) + .ToList(); + if (ModContext.DebugEnabled) { + ModLogger.LogInfo($"Found {candidates.Count} scene object(s) named '{prefabName}' with a Piece component."); + } + return candidates.FirstOrDefault(); + } + + // Bundles differ by mod: the template authors assets under Assets/Custom/... with a file extension, + // while EpicLoot/Jam/AdvancedPortals bundles use bare names. Try the bare name first, then suffixed. + private static T LoadBundleAsset(AssetBundle bundle, string name, string suffix) where T : UnityEngine.Object { + T asset = bundle.LoadAsset(name); + if (asset != null) { return asset; } + return name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? null : bundle.LoadAsset(name + suffix); + } + + private static void InitialPieceSetup(BuildPiece jbuildpiece) { + // Set where the recipe can be crafted. Gated on PiecesReady so an early config sync / file + // reload (both fire before ZNetScene.Awake) can't run before the scene prefab is resolved. + void RequiredBench_SettingChanged(object sender, EventArgs e) { + if (!PiecesReady || jbuildpiece.Objs.ScenePiece == null) { return; } + ConfigChangeDebouncer.Schedule(jbuildpiece.Cfgs.Workbench, () => ApplyWorkbench(jbuildpiece)); + } + jbuildpiece.Cfgs.RequiresWorkbench = ConfigBinder.BindServerConfig(jbuildpiece.Name, "Requires Workbench", jbuildpiece.RequiresWorkbench, $"Whether {jbuildpiece.Name} requires a crafting station to be built at all."); + jbuildpiece.Cfgs.RequiresWorkbench.SettingChanged += RequiredBench_SettingChanged; + jbuildpiece.Cfgs.Workbench = ConfigBinder.BindServerConfig(jbuildpiece.Name, "Workbench", jbuildpiece.Workbench, "The table required to allow building this piece, eg: 'forge', 'piece_workbench', 'blackforge', 'piece_artisanstation'."); + jbuildpiece.Cfgs.Workbench.SettingChanged += RequiredBench_SettingChanged; + + // Crafting cost change + void BuildRecipeChanged_SettingChanged(object sender, EventArgs e) { + if (!PiecesReady || jbuildpiece.Objs.ScenePiece == null) { return; } + ConfigChangeDebouncer.Schedule(sender, () => { + if (sender is ConfigEntry sendEntry) { + if (ModContext.DebugEnabled) { ModLogger.LogInfo($"Recieved new piece config {sendEntry.Value}"); } + // return if its an invalid change + if (PieceRecipeConfigUpdater(jbuildpiece, sendEntry.Value) == false) { return; } + } + ApplyRecipe(jbuildpiece); + }); + } + + // Setup enable/disable + jbuildpiece.Cfgs.Enabled = ConfigBinder.BindServerConfig(jbuildpiece.Name, "Enabled", jbuildpiece.Enabled, $"Enable/Disable the {jbuildpiece.Name}."); + jbuildpiece.Cfgs.Enabled.SettingChanged += BuildRecipeChanged_SettingChanged; + // Setup piece category + jbuildpiece.Cfgs.PieceCategory = ConfigBinder.BindServerConfig(jbuildpiece.Name, "Piece Category", jbuildpiece.Category, "Piece category for building.", PieceCategories.GetAcceptableValueList()); + void CraftingCategory_SettingChanged(object sender, EventArgs e) { + if (!PiecesReady || jbuildpiece.Objs.ScenePiece == null) { return; } + ConfigChangeDebouncer.Schedule(jbuildpiece.Cfgs.PieceCategory, () => ApplyCategory(jbuildpiece)); + } + jbuildpiece.Cfgs.PieceCategory.SettingChanged += CraftingCategory_SettingChanged; + + // Build out the internal default recipe + List raw_recipe_default = new List(); + foreach (PieceCost entry in jbuildpiece.PieceCost) { raw_recipe_default.Add($"{entry.Prefab},{entry.Amount},{entry.Refundable}"); } + string recipe_cfg_default = string.Join("|", raw_recipe_default.ToArray()); + // Wire up the config and on-change for piece costs + jbuildpiece.Cfgs.PieceCost = ConfigBinder.BindServerConfig(jbuildpiece.Name, "Building Cost", recipe_cfg_default, "Cost to build. Find item ids: https://valheim.fandom.com/wiki/Item_IDs Format: resouce_id,amount,refund eg: Wood,8,true|LeatherScraps,4,false", advanced: true); + if (PieceRecipeConfigUpdater(jbuildpiece, jbuildpiece.Cfgs.PieceCost.Value, false) == false) { + ModLogger.LogWarning($"{jbuildpiece.Name} has an invalid piece cost. The default will be used instead."); + PieceRecipeConfigUpdater(jbuildpiece, recipe_cfg_default, false); + } + + jbuildpiece.Cfgs.PieceCost.SettingChanged += BuildRecipeChanged_SettingChanged; + + // Collapse this piece's entries into a single grouped custom drawer to keep the in-game + // Configuration Manager responsive (one visible row per piece under "Building Pieces"). + PieceConfigDrawer.Attach(jbuildpiece); + + List recipe = new List(); + foreach (PieceCost entry in jbuildpiece.Cfgs.UpdatedCost) { + recipe.Add(new RequirementConfig { Item = entry.Prefab, Amount = entry.Amount, Recover = entry.Refundable }); + } + + // Build the jotunn piece definition + PieceConfig piececfg = new PieceConfig() { + CraftingStation = ResolveWorkbenchName(jbuildpiece), + PieceTable = jbuildpiece.PieceTable, + Category = jbuildpiece.Cfgs.PieceCategory.Value, + AllowedInDungeons = jbuildpiece.AllowedInDungeons, + Icon = jbuildpiece.Objs.Sprite, + Requirements = recipe.ToArray() + }; + // Add the updated piece to the piece manager + PieceManager.Instance.AddPiece(new CustomPiece(jbuildpiece.Objs.Prefab, fixReference: true, piececfg)); + } + + // Empty string / "none" / RequiresWorkbench=false all mean "buildable anywhere"; Jotunn wants null. + private static string ResolveWorkbenchName(BuildPiece jbuildpiece) { + bool requiresWorkbench = jbuildpiece.Cfgs.RequiresWorkbench?.Value ?? true; + string workbench = jbuildpiece.Cfgs.Workbench.Value; + if (!requiresWorkbench || string.IsNullOrEmpty(workbench) || workbench.ToLower() == "none") { return null; } + return workbench; + } + + // Applies the configured crafting station to the in-scene piece. Callers must ensure the scene + // prefab is resolved (PiecesReady) before invoking this. + private static void ApplyWorkbench(BuildPiece jbuildpiece) { + Piece piece = jbuildpiece.Objs.ScenePiece; + if (piece == null) { return; } + + string workbench = ResolveWorkbenchName(jbuildpiece); + if (workbench == null) { + if (ModContext.DebugEnabled) { ModLogger.LogInfo($"Setting required crafting station for {jbuildpiece.Name} to none."); } + piece.m_craftingStation = null; + return; + } + + CraftingStation craftable_at = PrefabManager.Instance.GetPrefab(workbench)?.GetComponent(); + if (craftable_at == null) { + ModLogger.LogWarning($"Required crafting station does not exist or does not have a crafting station component, check your prefab name ({workbench})."); + return; + } + + if (ModContext.DebugEnabled) { ModLogger.LogInfo($"Setting crafting station to {workbench}."); } + piece.m_craftingStation = craftable_at; + } + + // Applies the configured build category to the in-scene piece. + private static void ApplyCategory(BuildPiece jbuildpiece) { + Piece piece = jbuildpiece.Objs.ScenePiece; + if (piece == null) { return; } + + Piece.PieceCategory? category = PieceManager.Instance.GetPieceCategory(jbuildpiece.Cfgs.PieceCategory.Value); + if (category == null) { + category = PieceManager.Instance.AddPieceCategory(jbuildpiece.Cfgs.PieceCategory.Value); + } + piece.m_category = (Piece.PieceCategory)category; + } + + // Resolves the recipe in UpdatedCost against the live prefab database and applies it to the + // in-scene piece. Bails out (leaving the existing recipe intact) if any requirement prefab is not + // yet resolvable, so it is safe even if a dependency mod registered its items late. + private static void ApplyRecipe(BuildPiece jbuildpiece) { + Piece piece = jbuildpiece.Objs.ScenePiece; + if (piece == null) { return; } + + if (jbuildpiece.Cfgs.Enabled.Value == false) { + // Set this piece not craftable + piece.m_enabled = false; + return; + } + + List recipe = new List(); + if (ModContext.DebugEnabled) { ModLogger.LogInfo("Validating and building requirementsConfig"); } + foreach (PieceCost entry in jbuildpiece.Cfgs.UpdatedCost) { + if (PrefabManager.Instance.GetPrefab(entry.Prefab) == null) { + if (ModContext.DebugEnabled) { ModLogger.LogInfo($"{entry.Prefab} is not a valid prefab, skipping recipe update."); } + return; + } + if (ModContext.DebugEnabled) { ModLogger.LogInfo($"Checking entry {entry.Prefab} amount:{entry.Amount} refund?:{entry.Refundable}"); } + recipe.Add(new RequirementConfig { Item = entry.Prefab, Amount = entry.Amount, Recover = entry.Refundable }); + } + + if (ModContext.DebugEnabled) { ModLogger.LogInfo("Updating Piece."); } + List newRequirements = new List(); + foreach (RequirementConfig recipe_entry in recipe) { + Piece.Requirement piece_req = new Piece.Requirement(); + piece_req.m_resItem = PrefabManager.Instance.GetPrefab(recipe_entry.Item.Replace("JVLmock_", ""))?.GetComponent(); + piece_req.m_amount = recipe_entry.Amount; + piece_req.m_recover = recipe_entry.Recover; + newRequirements.Add(piece_req); + } + if (ModContext.DebugEnabled) { ModLogger.LogInfo($"Fixed mock requirements {newRequirements.Count}."); } + piece.m_resources = newRequirements.ToArray(); + // Re-enable as well as disable, so toggling Enabled back on takes effect without a restart. + piece.m_enabled = true; + } + + private static bool PieceRecipeConfigUpdater(BuildPiece jBuildPiece, string rawRecipe, bool during_runtime = true) { + string[] RawRecipeEntries = rawRecipe.Split('|'); + List updated_pieceRecipe = new List(); + foreach (string recipe_entry in RawRecipeEntries) { + string[] recipe_segments = recipe_entry.Split(','); + if (recipe_segments.Length != 3) { + ModLogger.LogWarning($"{recipe_entry} is invalid, it does not have enough segments. Proper format is: PREFABNAME,COST,REFUND_BOOL eg: Wood,8,false"); + return false; + } + // Add a sanity check to ensure the prefab we are trying to use exists + // This can only happen during runtime after pieces are available otherwise it will cause errors + if (during_runtime) { + if (PrefabManager.Instance.GetPrefab(recipe_segments[0]) == null) { + ModLogger.LogWarning($"{recipe_segments[0]} is an invalid prefab and does not exist."); + return false; + } + } + if (recipe_segments[0].Length == 0 || recipe_segments[1].Length == 0 || recipe_segments[2].Length == 0) { + ModLogger.LogWarning($"{recipe_entry} is invalid, one segment does not have enough data. Proper format is: PREFABNAME,CRAFT_COST,REFUND_BOOL eg: Wood,8,false"); + return false; + } + if (bool.TryParse(recipe_segments[2], out bool refund_flag_parse) == false) { + ModLogger.LogWarning($"{recipe_entry} is invalid, the REFUND_BOOL could not be parsed to (true/false). Proper format is: PREFABNAME,CRAFT_COST,REFUND_BOOL eg: Wood,8,false"); + return false; + } + if (int.TryParse(recipe_segments[1], out int amount_parse) == false) { + ModLogger.LogWarning($"{recipe_entry} is invalid, the CRAFT_COST could not be parsed to a whole number. Proper format is: PREFABNAME,CRAFT_COST,REFUND_BOOL eg: Wood,8,false"); + return false; + } + + if (ModContext.DebugEnabled) { + ModLogger.LogInfo($"prefab: {recipe_segments[0]} c:{recipe_segments[1]} u:{recipe_segments[2]}"); + } + updated_pieceRecipe.Add(new PieceCost() { Prefab = recipe_segments[0], Amount = amount_parse, Refundable = refund_flag_parse }); + } + + jBuildPiece.Cfgs.UpdatedCost.Clear(); + foreach (PieceCost entry in updated_pieceRecipe) { jBuildPiece.Cfgs.UpdatedCost.Add(entry); } + if (ModContext.DebugEnabled) { + string recipe_string = ""; + foreach (PieceCost entry in updated_pieceRecipe) { + recipe_string += $" {entry.Prefab} c:{entry.Amount} r:{entry.Refundable}"; + } + ModLogger.LogInfo($"Updated recipe:{recipe_string}"); + } + return true; + } + } +} diff --git a/EpicLoot-UnityLib/EpicLoot-UnityLib.csproj b/EpicLoot-UnityLib/EpicLoot-UnityLib.csproj deleted file mode 100644 index 6c1d698b4..000000000 --- a/EpicLoot-UnityLib/EpicLoot-UnityLib.csproj +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - Debug - AnyCPU - {8DABA031-AEA4-472F-A9AA-DE8B1AA69DE5} - Library - Properties - EpicLoot_UnityLib - EpicLoot-UnityLib - v4.8 - 10.0 - 512 - true - - - - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - true - latest - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - latest - - - - - - - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - - \ No newline at end of file diff --git a/EpicLoot-UnityLib/Properties/AssemblyInfo.cs b/EpicLoot-UnityLib/Properties/AssemblyInfo.cs deleted file mode 100644 index e97ec1d7b..000000000 --- a/EpicLoot-UnityLib/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("EpicLoot-UnityLib")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Randy Knapp Mods")] -[assembly: AssemblyProduct("EpicLoot-UnityLib")] -[assembly: AssemblyCopyright("Copyright © 2021")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("fb508e8d-0c64-4ec6-b746-f668f510296f")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0")] -[assembly: AssemblyFileVersion("1.0.0")] diff --git a/EpicLoot-UnityLib/packages.config b/EpicLoot-UnityLib/packages.config deleted file mode 100644 index 1063f24dc..000000000 --- a/EpicLoot-UnityLib/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/EpicLoot/.editorconfig b/EpicLoot/.editorconfig deleted file mode 100644 index ccedc8e85..000000000 --- a/EpicLoot/.editorconfig +++ /dev/null @@ -1,40 +0,0 @@ -[*] - -# https://marketplace.visualstudio.com/items?itemName=PaulHarrington.EditorGuidelinesPreview -guidelines = 120 - -[*.cs] - -# IDE0003: Remove qualification -dotnet_diagnostic.IDE0003.severity = none - -# IDE0051: Remove unused private members -dotnet_diagnostic.IDE0051.severity = none - -# IDE0060: Remove unused parameter -dotnet_diagnostic.IDE0060.severity = none - -# IDE0066: Convert switch statement to expression -dotnet_diagnostic.IDE0066.severity = none - -# IDE0090: 'new' expression can be simplified -dotnet_diagnostic.IDE0090.severity = none - -# IDE0130: Namespace does not match folder structure -dotnet_diagnostic.IDE0130.severity = none - -[*.cs] - -# Indentation and spacing -indent_size = 4 -indent_style = space -tab_width = 4 - -# New line preferences -csharp_new_line_before_catch = false -csharp_new_line_before_else = true -csharp_new_line_before_finally = false -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all -csharp_new_line_between_query_expression_clauses = true diff --git a/EpicLoot/API/API.cs b/EpicLoot/API/API.cs index 1da5b536e..9b0fcedba 100644 --- a/EpicLoot/API/API.cs +++ b/EpicLoot/API/API.cs @@ -4,7 +4,7 @@ using EpicLoot.Crafting; using EpicLoot.CraftingV2; using EpicLoot.LegendarySystem; -using EpicLoot.MagicItemEffects; +using EpicLoot.src.Magic.MagicItemEffects.Helpers; using JetBrains.Annotations; using Newtonsoft.Json; using System; @@ -37,6 +37,7 @@ public static partial class API /// static API() { + LootRoller.OnSetupLootTables += ReloadExternalLootTables; MagicItemEffectDefinitions.OnSetupMagicItemEffectDefinitions += ReloadExternalMagicEffects; UniqueLegendaryHelper.OnSetupLegendaryItemConfig += ReloadExternalLegendary; AbilityDefinitions.OnSetupAbilityDefinitions += ReloadExternalAbilities; diff --git a/EpicLoot/API/Events.cs b/EpicLoot/API/Events.cs new file mode 100644 index 000000000..08c4cd8cf --- /dev/null +++ b/EpicLoot/API/Events.cs @@ -0,0 +1,247 @@ +using JetBrains.Annotations; +using System; + +namespace EpicLoot; + +/// +/// Lifecycle notifications. Exposed as explicit Add/Remove listener calls rather than public +/// event fields so a reflection-only consumer can subscribe with a plain method call. +/// +/// Listeners run inside a try/catch; one that throws is logged with the exception and does not stop the +/// remaining listeners or the game action that raised it. +/// +public static partial class API +{ + /// + /// Reason tokens passed to a magic-item-changed listener. String constants rather than an enum, so + /// they cross the API boundary without a shared type. + /// + public static class ChangeReason + { + public const string Enchant = "Enchant"; + public const string Augment = "Augment"; + public const string Disenchant = "Disenchant"; + public const string Rune = "Rune"; + public const string Temper = "Temper"; + public const string Socket = "Socket"; + public const string Unsocket = "Unsocket"; + public const string LootRoll = "LootRoll"; + public const string Transfer = "Transfer"; + + /// + /// A write that has no dedicated call site. Every magic-data write funnels through + /// MagicItemComponent.SetMagicItem, so this is the catch-all that guarantees coverage. + /// + public const string Unspecified = "Unspecified"; + } + + private static event Action MagicItemChanged; + private static event Action LootGenerated; + private static event Action BountyCompleted; + + // Set while a semantic raise is in flight. SetMagicItem sits underneath every crafting operation, so + // without this a single enchant would report both "Enchant" and "Unspecified". + private static string _activeChangeReason; + + // Item load is not a change: MagicItemComponent.Load/FirstLoad call SetMagicItem to normalize data + // on every item that enters the world, which would otherwise flood listeners at world load. + internal static bool SuppressChangeEvents; + + private static void LogListenerFailure(string family, Exception ex) + { + // Force-logged: a listener that throws belongs to another plugin, and swallowing it silently + // makes the resulting missed-notification report impossible to diagnose. + EpicLoot.LogErrorForce($"[EpicLoot.API] {family} listener threw: {ex}"); + } + + /// + /// Fires whenever an item's magic data is written -- enchant, augment, disenchant, rune extract, + /// temper, socket, unsocket, loot roll, or any other write. + /// + /// Receives the item and a token. The item's new + /// magic data is already committed when this runs. + /// true if subscribed + [PublicAPI] + public static bool AddMagicItemChangedListener(Action listener) + { + if (listener == null) + { + return false; + } + + MagicItemChanged += listener; + return true; + } + + /// true if the listener was non-null; unsubscribing something never subscribed is a no-op + [PublicAPI] + public static bool RemoveMagicItemChangedListener(Action listener) + { + if (listener == null) + { + return false; + } + + MagicItemChanged -= listener; + return true; + } + + /// + /// Fires for each magic item Epic Loot rolls as loot, after its magic data is applied and before it + /// is handed to the world. + /// + [PublicAPI] + public static bool AddLootGeneratedListener(Action listener) + { + if (listener == null) + { + return false; + } + + LootGenerated += listener; + return true; + } + + /// true if the listener was non-null + [PublicAPI] + public static bool RemoveLootGeneratedListener(Action listener) + { + if (listener == null) + { + return false; + } + + LootGenerated -= listener; + return true; + } + + /// + /// Fires on the claiming player's client when an adventure-mode bounty reward is collected. + /// + /// Receives the player and the bounty's target monster id + [PublicAPI] + public static bool AddBountyCompletedListener(Action listener) + { + if (listener == null) + { + return false; + } + + BountyCompleted += listener; + return true; + } + + /// true if the listener was non-null + [PublicAPI] + public static bool RemoveBountyCompletedListener(Action listener) + { + if (listener == null) + { + return false; + } + + BountyCompleted -= listener; + return true; + } + + /// + /// Labels the write performed by , so the event SetMagicItem raises + /// carries a real reason token instead of . The event still + /// fires exactly once, from the funnel. + /// + /// + /// Nested calls keep the outermost reason: socketing re-hosts effects through several writes, and + /// the caller's intent is the interesting one. + /// + internal static void WithChangeReason(string reason, Action write) + { + if (_activeChangeReason != null) + { + write(); + return; + } + + _activeChangeReason = reason; + try + { + write(); + } + finally + { + _activeChangeReason = null; + } + } + + /// + /// Called from MagicItemComponent.SetMagicItem, the single funnel every magic-data write + /// passes through. Picks up the reason from an enclosing , if any. + /// + internal static void RaiseMagicItemChanged(ItemDrop.ItemData item) + { + RaiseMagicItemChanged(item, _activeChangeReason ?? ChangeReason.Unspecified); + } + + /// + /// Direct raise for operations that do not write through SetMagicItem -- disenchanting and a + /// fully-extracted rune both drop the component outright rather than writing to it. + /// + internal static void RaiseMagicItemChanged(ItemDrop.ItemData item, string reason) + { + if (SuppressChangeEvents || item == null || MagicItemChanged == null) + { + return; + } + + foreach (Delegate handler in MagicItemChanged.GetInvocationList()) + { + try + { + ((Action)handler)(item, reason); + } + catch (Exception ex) + { + LogListenerFailure("MagicItemChanged", ex); + } + } + } + + internal static void RaiseLootGenerated(ItemDrop.ItemData item) + { + if (item == null || LootGenerated == null) + { + return; + } + + foreach (Delegate handler in LootGenerated.GetInvocationList()) + { + try + { + ((Action)handler)(item); + } + catch (Exception ex) + { + LogListenerFailure("LootGenerated", ex); + } + } + } + + internal static void RaiseBountyCompleted(Player player, string targetId) + { + if (BountyCompleted == null) + { + return; + } + + foreach (Delegate handler in BountyCompleted.GetInvocationList()) + { + try + { + ((Action)handler)(player, targetId); + } + catch (Exception ex) + { + LogListenerFailure("BountyCompleted", ex); + } + } + } +} diff --git a/EpicLoot/API/Helpers.cs b/EpicLoot/API/Helpers.cs index dc828948d..b307e8018 100644 --- a/EpicLoot/API/Helpers.cs +++ b/EpicLoot/API/Helpers.cs @@ -2,6 +2,7 @@ using EpicLoot.Crafting; using JetBrains.Annotations; using Newtonsoft.Json; +using System; using System.Collections.Generic; using System.Reflection; @@ -9,7 +10,15 @@ namespace EpicLoot; public static partial class API { + /// + /// Message shared by the deprecated recipe endpoints below. + /// + private const string RecipeApiObsolete = "Epic Loot's recipes.json was removed in 0.13.0 and it no longer ships the recipes.json file. "; + + /// JSON serialized + /// unique key if successfully added [PublicAPI] + [Obsolete(RecipeApiObsolete)] public static string AddRecipe(string json) { try @@ -34,6 +43,7 @@ public static string AddRecipe(string json) /// JSON serialized List of /// unique key if successfully added [PublicAPI] + [Obsolete(RecipeApiObsolete)] public static string AddRecipes(string json) { // TODO: Figure out why it looks like recipes are added twice @@ -61,9 +71,10 @@ public static string AddRecipes(string json) } /// unique identifier - /// JSON serialized List of + /// JSON serialized List of /// True if updated [PublicAPI] + [Obsolete(RecipeApiObsolete)] public static bool UpdateRecipes(string key, string json) { if (!RuntimeRegistry.TryGetValue(key, out List list)) diff --git a/EpicLoot/API/Loot.cs b/EpicLoot/API/Loot.cs new file mode 100644 index 000000000..6f1786717 --- /dev/null +++ b/EpicLoot/API/Loot.cs @@ -0,0 +1,311 @@ +using EpicLoot.LegendarySystem; +using JetBrains.Annotations; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace EpicLoot; + +/// +/// Generating magic items and extending the loot tables from another plugin. This is the supported +/// replacement for compiling against a publicized EpicLoot.dll and calling LootRoller +/// directly, which breaks on every internal refactor. +/// +public static partial class API +{ + /// + /// Rolls a magic item of the given rarity and stamps it onto , reproducing + /// the whole drop flow: effect selection, socket count, legendary/mythic assignment, randomized + /// wear, and the display name. + /// + /// The item to enchant in place. Must satisfy . + /// rarity ordinal + /// Luck factor, as returned by . Pass 0 for none. + /// Optional legendary/mythic id to force. Ignored when empty; the roll + /// fails rather than silently downgrading if the id is unknown or its requirements do not fit + /// . + /// true if the item is now magic + [PublicAPI] + public static bool TryMakeMagicItem(ItemDrop.ItemData item, int rarity, float luck, string legendaryID) + { + if (!EpicLoot.CanBeMagicItem(item) || !TryToRarity(rarity, out ItemRarity value)) + { + return false; + } + + try + { + MagicItem magicItem = LootRoller.RollMagicItem(value, item, luck); + if (magicItem == null) + { + return false; + } + + if (!string.IsNullOrEmpty(legendaryID)) + { + if (!UniqueLegendaryHelper.TryGetLegendaryInfo(legendaryID, out LegendaryInfo info) + || !info.Requirements.CheckRequirements(item, magicItem)) + { + OnError?.Invoke($"TryMakeMagicItem: legendary '{legendaryID}' is unknown or does not fit {item.m_shared.m_name}"); + return false; + } + + magicItem.LegendaryID = legendaryID; + magicItem.DisplayName = info.Name; + magicItem.SetID = UniqueLegendaryHelper.GetSetForLegendaryItem(info); + } + + WithChangeReason(ChangeReason.LootRoll, () => item.SaveMagicItem(magicItem)); + LootRoller.InitializeMagicItem(item); + RaiseLootGenerated(item); + return true; + } + catch (Exception ex) + { + EpicLoot.LogErrorForce($"[EpicLoot.API] TryMakeMagicItem failed for {item.m_shared?.m_name}: {ex}"); + return false; + } + } + + /// + /// Rolls a magic item without applying it, for callers that want to inspect or adjust it first. + /// + /// rarity ordinal + /// The item the roll is for; effect selection depends on it. + /// Luck factor, 0 for none + /// JSON serialized MagicItem, or "" if the roll was not possible + [PublicAPI] + public static string RollMagicItemJson(int rarity, ItemDrop.ItemData item, float luck) + { + if (item == null || !TryToRarity(rarity, out ItemRarity value)) + { + return ""; + } + + try + { + MagicItem magicItem = LootRoller.RollMagicItem(value, item, luck); + return magicItem == null ? "" : JsonConvert.SerializeObject(magicItem); + } + catch (Exception ex) + { + EpicLoot.LogErrorForce($"[EpicLoot.API] RollMagicItemJson failed: {ex}"); + return ""; + } + } + + /// + /// Applies previously rolled or hand-authored magic data to an item. + /// + /// The item to write to + /// JSON serialized MagicItem, as returned by + /// or + /// true if applied + [PublicAPI] + public static bool ApplyMagicItemJson(ItemDrop.ItemData item, string magicItemJson) + { + if (item == null || string.IsNullOrEmpty(magicItemJson)) + { + return false; + } + + try + { + MagicItem magicItem = JsonConvert.DeserializeObject(magicItemJson); + if (magicItem == null) + { + return false; + } + + item.SaveMagicItem(magicItem); + return true; + } + catch (Exception ex) + { + OnError?.Invoke($"ApplyMagicItemJson: could not parse magic item json: {ex.Message}"); + return false; + } + } + + /// World position the loot is dropping at + /// The luck factor in effect there, from the world luck setting and nearby players' Luck + /// magic effects. + [PublicAPI] + public static float GetLuckFactor(Vector3 position) + { + return LootRoller.GetLuckFactor(position); + } + + /// rarity ordinal + /// How many effects a fresh roll of this rarity would get; 0 for an unknown rarity. + [PublicAPI] + public static int RollEffectCountForRarity(int rarity) + { + return TryToRarity(rarity, out ItemRarity value) ? LootRoller.RollEffectCountPerRarity(value) : 0; + } + + /// rarity ordinal; only Legendary and Mythic have entries + /// Every registered legendary/mythic id at that rarity, including API-registered ones. + [PublicAPI] + public static List GetLegendaryIDs(int rarity) + { + if (!TryToRarity(rarity, out ItemRarity value)) + { + return new List(); + } + + switch (value) + { + case ItemRarity.Legendary: + return UniqueLegendaryHelper.LegendaryInfo.Keys.ToList(); + case ItemRarity.Mythic: + return UniqueLegendaryHelper.MythicInfo.Keys.ToList(); + default: + return new List(); + } + } + + /// A legendary or mythic id + /// JSON serialized LegendaryInfo, or "" if unknown + [PublicAPI] + public static string GetLegendaryInfoJson(string legendaryID) + { + if (string.IsNullOrEmpty(legendaryID) + || !UniqueLegendaryHelper.TryGetLegendaryInfo(legendaryID, out LegendaryInfo info)) + { + return ""; + } + + return JsonConvert.SerializeObject(info); + } + + /// + /// The effect types that could legally be rolled onto this item, honoring rarity, item type, skill, + /// exclusivity and any requirement registered via . + /// + /// The candidate item + /// The magic data the item would have; pass "" for an empty roll of + /// + /// rarity ordinal, used only when is empty + /// The available effect type ids + [PublicAPI] + public static List GetAvailableEffectTypes(ItemDrop.ItemData item, string magicItemJson, int rarity) + { + if (item == null) + { + return new List(); + } + + try + { + MagicItem magicItem = string.IsNullOrEmpty(magicItemJson) + ? null + : JsonConvert.DeserializeObject(magicItemJson); + + if (magicItem == null) + { + if (!TryToRarity(rarity, out ItemRarity value)) + { + return new List(); + } + + magicItem = new MagicItem { Rarity = value }; + } + + return MagicItemEffectDefinitions.GetAvailableEffects(item, magicItem) + .Select(x => x.Type) + .ToList(); + } + catch (Exception ex) + { + OnError?.Invoke($"GetAvailableEffectTypes failed: {ex.Message}"); + return new List(); + } + } + + /// + /// Adds a loot table at runtime. Registrations are cached and re-applied whenever loottables.json + /// reloads or a dedicated server pushes its copy, so they survive both. + /// + /// JSON serialized or an array of them + /// unique key if added, else null + [PublicAPI] + public static string AddLootTables(string json) + { + if (string.IsNullOrEmpty(json)) + { + return null; + } + + try + { + List tables = json.TrimStart().StartsWith("[") + ? JsonConvert.DeserializeObject>(json) + : new List { JsonConvert.DeserializeObject(json) }; + + tables?.RemoveAll(x => x == null); + if (tables == null || tables.Count == 0) + { + return null; + } + + ExternalLootTables.AddRange(tables); + LootRoller.AddLootTables(tables); + return RuntimeRegistry.Register(tables); + } + catch (Exception ex) + { + OnError?.Invoke($"Failed to parse loot table passed in through external plugin: {ex.Message}"); + return null; + } + } + + /// unique identifier returned by + /// JSON serialized list of + /// True if updated + [PublicAPI] + public static bool UpdateLootTables(string key, string json) + { + if (!RuntimeRegistry.TryGetValue(key, out List existing)) + { + return false; + } + + List tables = JsonConvert.DeserializeObject>(json); + if (tables == null) + { + return false; + } + + // AddLootTable appends, so the previous instances have to be dropped by reference first or both + // versions would roll. + LootRoller.RemoveLootTables(existing); + ExternalLootTables.RemoveAll(existing); + + ExternalLootTables.AddRange(tables); + existing.Clear(); + existing.AddRange(tables); + + LootRoller.AddLootTables(tables); + return true; + } + + private static readonly List ExternalLootTables = new(); + + /// + /// Re-applies cached external loot tables into . Subscribed to + /// in the API static constructor. + /// + private static void ReloadExternalLootTables() + { + if (ExternalLootTables.Count == 0) + { + return; + } + + LootRoller.AddLootTables(ExternalLootTables); + OnReload?.Invoke("Reloaded external loot tables"); + } +} diff --git a/EpicLoot/API/MagicEffectRequirement.cs b/EpicLoot/API/MagicEffectRequirement.cs new file mode 100644 index 000000000..e99660e45 --- /dev/null +++ b/EpicLoot/API/MagicEffectRequirement.cs @@ -0,0 +1,89 @@ +using JetBrains.Annotations; +using System; +using System.Collections.Generic; + +namespace EpicLoot; + +public static partial class API +{ + private static readonly Dictionary> ExternalMagicEffectRequirements = new(); + + /// + /// Registers a magic effect requirement predicate for a Requirements.ExternalRequirements value. + /// + /// The ExternalRequirements value used by magic effect JSON. + /// + /// Predicate arguments are item data, magic item, magic effect type, check loot roll, check augment roll, and check rune roll. + /// + /// true if the requirement was registered + [PublicAPI] + public static bool RegisterMagicEffectRequirement(string externalRequirement, Func requirement) + { + if (string.IsNullOrWhiteSpace(externalRequirement)) + { + OnError?.Invoke("Failed to register magic effect requirement: external requirement is empty"); + return false; + } + + if (requirement == null) + { + OnError?.Invoke($"Failed to register magic effect requirement '{externalRequirement}': requirement is null"); + return false; + } + + if (ExternalMagicEffectRequirements.ContainsKey(externalRequirement)) + { + OnError?.Invoke($"Duplicate magic effect requirement: {externalRequirement}"); + return false; + } + + ExternalMagicEffectRequirements[externalRequirement] = requirement; + OnReload?.Invoke($"Registered magic effect requirement: {externalRequirement}"); + return true; + } + + internal static bool CheckMagicEffectExternalRequirements( + List externalRequirements, + ItemDrop.ItemData itemData, + MagicItem magicItem, + string magicEffectType, + bool checklootroll, + bool checkaugmentroll, + bool checkruneroll) + { + if (externalRequirements == null || externalRequirements.Count == 0) + { + return true; + } + + foreach (string externalRequirement in externalRequirements) + { + if (string.IsNullOrWhiteSpace(externalRequirement)) + { + OnError?.Invoke($"Magic effect '{magicEffectType}' has an empty external requirement"); + return false; + } + + if (!ExternalMagicEffectRequirements.TryGetValue(externalRequirement, out Func requirement)) + { + OnError?.Invoke($"Magic effect '{magicEffectType}' requires unregistered external requirement '{externalRequirement}'"); + return false; + } + + try + { + if (!requirement(itemData, magicItem, magicEffectType, checklootroll, checkaugmentroll, checkruneroll)) + { + return false; + } + } + catch (Exception ex) + { + OnError?.Invoke($"Magic effect requirement '{externalRequirement}' failed for '{magicEffectType}': {ex.Message}"); + return false; + } + } + + return true; + } +} diff --git a/EpicLoot/API/Providers.cs b/EpicLoot/API/Providers.cs new file mode 100644 index 000000000..43f233110 --- /dev/null +++ b/EpicLoot/API/Providers.cs @@ -0,0 +1,449 @@ +using JetBrains.Annotations; +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace EpicLoot; + +/// +/// Extension points that let another plugin widen what Epic Loot considers "available" without patching +/// it. Each family replaces a set of Harmony patches integrating mods write today -- see the migration +/// table in docs/API.md. +/// +/// Every registered delegate is invoked inside a try/catch. A provider that throws is logged with its id +/// (unconditionally, since it is another plugin's bug) and treated as contributing nothing, so a broken +/// integration degrades to vanilla behavior rather than breaking the enchanting table. +/// +public static partial class API +{ + private class InventoryProvider + { + public string Id; + public Func> GetItems; + public Func CountItem; + public Func RemoveItem; + public Func RemoveExactItem; + } + + private static readonly List InventoryProviders = new(); + private static readonly Dictionary>> EquipmentProviders = new(); + private static readonly Dictionary> SacrificeFilters = new(); + + // Checked before every provider dispatch. Registration is rare and dispatch is not, so the common + // case must cost one bool read. + internal static bool AnyInventoryProviders; + internal static bool AnyEquipmentProviders; + internal static bool AnySacrificeFilters; + + private static void LogProviderFailure(string family, string id, Exception ex) + { + // Force-logged: this is a fault in the registering plugin, and silently swallowing it would make + // the resulting "my items don't count" report impossible to diagnose. + EpicLoot.LogErrorForce($"[EpicLoot.API] {family} provider '{id}' threw and was skipped: {ex}"); + } + + #region Inventory providers + + /// + /// Contributes additional items to the enchanting table's view of the player's inventory -- nearby + /// containers, a backpack, a remote stash. Epic Loot always draws from the player's own inventory + /// first and only falls through to providers for the shortfall. + /// + /// Unique id for this registration, normally your plugin GUID. Used for + /// unregistration and in error messages. + /// Returns the extra items the table may see. Return the live instances, not + /// copies -- Epic Loot needs instance identity to preserve magic data. May be null. + /// Given m_shared.m_name, returns how many you can supply. May be null. + /// Given m_shared.m_name and a requested amount, consumes up to that + /// amount and returns how many were actually removed. May be null. + /// Consumes a specific item instance (match by reference, not by name -- + /// matching by name will destroy the wrong enchanted item) and returns how many were removed. + /// May be null. + /// true if registered; false if is empty or already registered + [PublicAPI] + public static bool RegisterInventoryProvider( + string id, + Func> getItems, + Func countItem, + Func removeItem, + Func removeExactItem) + { + if (string.IsNullOrWhiteSpace(id)) + { + OnError?.Invoke("Failed to register inventory provider: id is empty"); + return false; + } + + if (InventoryProviders.Exists(x => x.Id == id)) + { + OnError?.Invoke($"Duplicate inventory provider: {id}"); + return false; + } + + InventoryProviders.Add(new InventoryProvider + { + Id = id, + GetItems = getItems, + CountItem = countItem, + RemoveItem = removeItem, + RemoveExactItem = removeExactItem + }); + AnyInventoryProviders = true; + OnReload?.Invoke($"Registered inventory provider: {id}"); + return true; + } + + /// The id passed to + /// true if a registration was removed + [PublicAPI] + public static bool UnregisterInventoryProvider(string id) + { + bool removed = InventoryProviders.RemoveAll(x => x.Id == id) > 0; + AnyInventoryProviders = InventoryProviders.Count > 0; + return removed; + } + + /// + /// Appends every provider-supplied item to . The caller must own that list -- + /// never pass a live Inventory.m_inventory. + /// + internal static void AppendProviderItems(List into) + { + if (!AnyInventoryProviders || into == null) + { + return; + } + + foreach (InventoryProvider provider in InventoryProviders) + { + if (provider.GetItems == null) + { + continue; + } + + try + { + List items = provider.GetItems(); + if (items == null) + { + continue; + } + + foreach (ItemDrop.ItemData item in items) + { + // Guard against a provider handing back something the player already holds; the + // table would otherwise count it twice. + if (item != null && !into.Contains(item)) + { + into.Add(item); + } + } + } + catch (Exception ex) + { + LogProviderFailure("Inventory", provider.Id, ex); + } + } + } + + /// m_shared.m_name + /// How many of all providers together can supply. + internal static int CountProviderItems(string itemName) + { + if (!AnyInventoryProviders) + { + return 0; + } + + int total = 0; + foreach (InventoryProvider provider in InventoryProviders) + { + if (provider.CountItem == null) + { + continue; + } + + try + { + total += Math.Max(0, provider.CountItem(itemName)); + } + catch (Exception ex) + { + LogProviderFailure("Inventory", provider.Id, ex); + } + } + + return total; + } + + /// Drains across providers in registration order. + /// How many were actually removed, which may be less than requested. + internal static int RemoveProviderItems(string itemName, int amount) + { + if (!AnyInventoryProviders || amount <= 0) + { + return 0; + } + + int remaining = amount; + foreach (InventoryProvider provider in InventoryProviders) + { + if (remaining <= 0) + { + break; + } + + if (provider.RemoveItem == null) + { + continue; + } + + try + { + // Clamp: a provider that over-reports would otherwise make the caller believe it + // consumed more than it asked for. + int removed = Mathf.Clamp(provider.RemoveItem(itemName, remaining), 0, remaining); + remaining -= removed; + } + catch (Exception ex) + { + LogProviderFailure("Inventory", provider.Id, ex); + } + } + + return amount - remaining; + } + + /// Drains a specific item instance across providers. + /// How many were actually removed. + internal static int RemoveExactProviderItem(ItemDrop.ItemData item, int amount) + { + if (!AnyInventoryProviders || item == null || amount <= 0) + { + return 0; + } + + int remaining = amount; + foreach (InventoryProvider provider in InventoryProviders) + { + if (remaining <= 0) + { + break; + } + + if (provider.RemoveExactItem == null) + { + continue; + } + + try + { + int removed = Mathf.Clamp(provider.RemoveExactItem(item, remaining), 0, remaining); + remaining -= removed; + } + catch (Exception ex) + { + LogProviderFailure("Inventory", provider.Id, ex); + } + } + + return amount - remaining; + } + + #endregion + + #region Equipment providers + + /// + /// Contributes additional equipped items for magic effect resolution -- extra equipment slots, quick + /// slots, an equipped backpack. Feeds PlayerExtensions.GetMagicEquipment, so contributed items + /// count toward effect totals, legendary set bonuses, tooltips, and shard socketing alike. + /// + /// + /// Results are memoized per player in , which only invalidates on + /// vanilla Humanoid.EquipItem/UnequipItem. If your slots change outside those methods, + /// call or the old values will keep being served. + /// + /// Unique id, normally your plugin GUID + /// Returns the items equipped in your slots for that player. Non-magic + /// items are filtered out by Epic Loot, so returning everything is fine. + /// true if registered + [PublicAPI] + public static bool RegisterEquipmentProvider(string id, Func> getExtraEquipped) + { + if (string.IsNullOrWhiteSpace(id)) + { + OnError?.Invoke("Failed to register equipment provider: id is empty"); + return false; + } + + if (getExtraEquipped == null) + { + OnError?.Invoke($"Failed to register equipment provider '{id}': callback is null"); + return false; + } + + if (EquipmentProviders.ContainsKey(id)) + { + OnError?.Invoke($"Duplicate equipment provider: {id}"); + return false; + } + + EquipmentProviders[id] = getExtraEquipped; + AnyEquipmentProviders = true; + OnReload?.Invoke($"Registered equipment provider: {id}"); + return true; + } + + /// The id passed to + /// true if a registration was removed + [PublicAPI] + public static bool UnregisterEquipmentProvider(string id) + { + bool removed = id != null && EquipmentProviders.Remove(id); + AnyEquipmentProviders = EquipmentProviders.Count > 0; + return removed; + } + + /// + /// Discards the memoized magic effect totals for a player, forcing the next read to recompute, and + /// refreshes the equip effect visuals worn on the player. Call after changing the contents of slots + /// you provide. + /// + /// may be null + [PublicAPI] + public static void InvalidatePlayerEffectCache(Player player) + { + if (player != null) + { + EquipmentEffectCache.Reset(player); + VisEquipment_Patch.RefreshPlayerFx(player); + } + } + + /// + /// Appends provider-supplied equipped items to , keeping only magic ones and + /// skipping anything already present. + /// + internal static void AppendProviderEquipment(Player player, List into) + { + if (!AnyEquipmentProviders || player == null || into == null) + { + return; + } + + foreach (KeyValuePair>> entry in EquipmentProviders) + { + try + { + List items = entry.Value(player); + if (items == null) + { + continue; + } + + foreach (ItemDrop.ItemData item in items) + { + if (item != null && item.IsMagic() && !into.Contains(item)) + { + into.Add(item); + } + } + } + catch (Exception ex) + { + LogProviderFailure("Equipment", entry.Key, ex); + } + } + } + + #endregion + + #region Sacrifice filters + + /// + /// Vetoes sacrificing (disenchanting) specific items -- for instance to stop an item equipped in a + /// slot Epic Loot cannot see from being destroyed. A filter returning false makes the item yield no + /// sacrifice products, which hides it from the Sacrifice tab. + /// + /// Unique id, normally your plugin GUID + /// Return false to veto. Called for every item the tab evaluates, so keep + /// it cheap. + /// true if registered + [PublicAPI] + public static bool RegisterSacrificeFilter(string id, Func canSacrifice) + { + if (string.IsNullOrWhiteSpace(id)) + { + OnError?.Invoke("Failed to register sacrifice filter: id is empty"); + return false; + } + + if (canSacrifice == null) + { + OnError?.Invoke($"Failed to register sacrifice filter '{id}': callback is null"); + return false; + } + + if (SacrificeFilters.ContainsKey(id)) + { + OnError?.Invoke($"Duplicate sacrifice filter: {id}"); + return false; + } + + SacrificeFilters[id] = canSacrifice; + AnySacrificeFilters = true; + OnReload?.Invoke($"Registered sacrifice filter: {id}"); + return true; + } + + /// The id passed to + /// true if a registration was removed + [PublicAPI] + public static bool UnregisterSacrificeFilter(string id) + { + bool removed = id != null && SacrificeFilters.Remove(id); + AnySacrificeFilters = SacrificeFilters.Count > 0; + return removed; + } + + /// false if any filter vetoes sacrificing this item. A filter that throws does not veto. + internal static bool SacrificeAllowed(ItemDrop.ItemData item) + { + if (!AnySacrificeFilters || item == null) + { + return true; + } + + foreach (KeyValuePair> entry in SacrificeFilters) + { + try + { + if (!entry.Value(item)) + { + return false; + } + } + catch (Exception ex) + { + LogProviderFailure("Sacrifice", entry.Key, ex); + } + } + + return true; + } + + #endregion + + /// The ids of every registered provider, grouped by family, for diagnostics. + [PublicAPI] + public static Dictionary> GetRegisteredProviders() + { + return new Dictionary> + { + ["Inventory"] = InventoryProviders.ConvertAll(x => x.Id), + ["Equipment"] = new List(EquipmentProviders.Keys), + ["Sacrifice"] = new List(SacrificeFilters.Keys) + }; + } +} diff --git a/EpicLoot/API/Query.cs b/EpicLoot/API/Query.cs new file mode 100644 index 000000000..6461462c6 --- /dev/null +++ b/EpicLoot/API/Query.cs @@ -0,0 +1,251 @@ +using EpicLoot.Crafting; +using JetBrains.Annotations; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace EpicLoot; + +/// +/// Read-only questions an external plugin can ask about an item. Everything here is non-throwing and +/// null-safe: the underlying extension methods are not ( throws +/// for a non-magic item, throws for an out-of-range rarity), so +/// this layer wraps rather than exposes that behavior. +/// +/// Rarity crosses this boundary as an , never the enum -- see +/// the signature rule in docs/API.md. +/// +public static partial class API +{ + private static readonly int RarityCount = Enum.GetValues(typeof(ItemRarity)).Length; + + /// + /// Converts a boundary int to an . + /// + /// false if the value names no rarity, in which case nothing should be assumed about + /// + private static bool TryToRarity(int rarity, out ItemRarity result) + { + result = default; + if (rarity < 0 || rarity >= RarityCount) + { + return false; + } + + result = (ItemRarity)rarity; + return true; + } + + /// may be null + /// true if the item carries Epic Loot magic data + [PublicAPI] + public static bool IsMagicItem(ItemDrop.ItemData item) + { + return item != null && item.IsMagic(); + } + + /// + /// The rarity of a magic item, magic crafting material, or runestone. + /// + /// may be null + /// receives the rarity ordinal; unchanged when this returns false + /// true if the item has a rarity at all + [PublicAPI] + public static bool TryGetRarity(ItemDrop.ItemData item, ref int rarity) + { + if (item == null || !item.HasRarity()) + { + return false; + } + + rarity = (int)item.GetRarity(); + return true; + } + + /// + /// The name Epic Loot shows for an item -- the generated magic name ("Sturdy Rag Trousers of the + /// Bear") where it has one, the vanilla m_shared.m_name otherwise. Any parenthesised suffix + /// on the vanilla name, such as a variant's stat text, is carried over. + /// + /// may be null + /// The unlocalized display name, or "" for a null item + [PublicAPI] + public static string GetItemDisplayName(ItemDrop.ItemData item) + { + return item == null ? "" : item.GetDisplayName(); + } + + /// + /// wrapped in a color tag, which is how Epic Loot's own UI renders + /// item names. Note the tag overrides whatever color the text component is set to. + /// + /// may be null + /// A color to use instead of the item's rarity color, either a hex + /// string ("#808080ff") or a name ("white") -- pass this to dim a name that would otherwise ignore + /// your own tinting. May be null. + /// The decorated display name, or "" for a null item + [PublicAPI] + public static string GetItemDecoratedName(ItemDrop.ItemData item, string colorOverride = null) + { + return item == null ? "" : item.GetDecoratedName(colorOverride); + } + + /// The number of rarity tiers, so a consumer can enumerate 0..n-1 without hard-coding 5. + [PublicAPI] + public static int GetRarityCount() + { + return RarityCount; + } + + /// rarity ordinal + /// The configured hex color, e.g. "#AA55FF". White for an unknown rarity. + [PublicAPI] + public static string GetRarityColorByIndex(int rarity) + { + return TryToRarity(rarity, out ItemRarity value) ? EpicLoot.GetRarityColor(value) : "#FFFFFF"; + } + + /// rarity ordinal + /// The unlocalized display token, e.g. "$mod_epicloot_Epic". + [PublicAPI] + public static string GetRarityDisplayNameByIndex(int rarity) + { + return TryToRarity(rarity, out ItemRarity value) ? EpicLoot.GetRarityDisplayName(value) : ""; + } + + /// + /// The color Epic Loot draws this specific item's name in -- magic rarity, crafting material rarity, + /// or runestone rarity, whichever applies. + /// + /// may be null + /// hex color, "#FFFFFF" when the item has no rarity + [PublicAPI] + public static string GetItemRarityColor(ItemDrop.ItemData item) + { + if (item == null || !item.HasRarity()) + { + return "#FFFFFF"; + } + + return GetRarityColorByIndex((int)item.GetRarity()); + } + + /// + /// Whether this item was added by Epic Loot (shard stone, runestone, crafting material, bounty token, + /// and so on). Supported replacement for testing m_shared.m_name.StartsWith("$mod_epicloot"). + /// + /// may be null + [PublicAPI] + public static bool IsEpicLootItem(ItemDrop.ItemData item) + { + if (item?.m_shared == null) + { + return false; + } + + return item.m_shared.m_name.StartsWith("$mod_epicloot", StringComparison.Ordinal) + || item.IsShardStone() + || item.IsRunestone() + || item.IsMagicCraftingMaterial() + || item.IsUnidentifiedMaterial(); + } + + /// may be null + [PublicAPI] + public static bool IsShardStone(ItemDrop.ItemData item) + { + return item != null && item.IsShardStone(); + } + + /// may be null + [PublicAPI] + public static bool IsRunestone(ItemDrop.ItemData item) + { + return item != null && item.IsRunestone(); + } + + /// may be null + [PublicAPI] + public static bool IsMagicCraftingMaterial(ItemDrop.ItemData item) + { + return item != null && item.IsMagicCraftingMaterial(); + } + + /// + /// An unidentified item is magic data that has not been revealed yet; it reports true from + /// but its effects should not be treated as known. + /// + /// may be null + [PublicAPI] + public static bool IsUnidentified(ItemDrop.ItemData item) + { + return item != null && item.IsUnidentified(); + } + + /// + /// Whether Epic Loot would ever allow this item to become magic (player item, non-stackable, not on + /// the restricted list, an allowed item type). Check this before calling + /// . + /// + /// may be null + [PublicAPI] + public static bool CanBeMagicItem(ItemDrop.ItemData item) + { + return EpicLoot.CanBeMagicItem(item); + } + + /// may be null + /// + /// count effects granted by socketed shards and runestones + [PublicAPI] + public static bool ItemHasMagicEffect(ItemDrop.ItemData item, string effectType, bool includeSocketed) + { + return item != null && item.HasMagicEffect(effectType, includeSocketed); + } + + /// Every registered magic effect type id, including any added through the API. + [PublicAPI] + public static List GetAllMagicEffectTypes() + { + return MagicItemEffectDefinitions.AllDefinitions.Keys.ToList(); + } + + /// + /// The material cost of enchanting this item to the given rarity. Supported replacement for + /// reflecting the long-removed EnchantTabController.GetEnchantCosts. + /// + /// may be null + /// rarity ordinal + /// JSON list of ({ "Item": prefabName, "Amount": n }), or + /// "[]" when no cost is configured + [PublicAPI] + public static string GetEnchantCostsJson(ItemDrop.ItemData item, int rarity) + { + if (item == null || !TryToRarity(rarity, out ItemRarity value)) + { + return "[]"; + } + + List costs = EnchantCostsHelper.GetEnchantCost(item, value); + return JsonConvert.SerializeObject(costs ?? new List()); + } + + /// + /// What sacrificing (disenchanting) this item yields. Honors any filter registered through + /// , so an empty result means "not sacrificeable". + /// + /// may be null + /// JSON list of , or "[]" + [PublicAPI] + public static string GetSacrificeProductsJson(ItemDrop.ItemData item) + { + if (item == null) + { + return "[]"; + } + + List products = EnchantCostsHelper.GetSacrificeProducts(item); + return JsonConvert.SerializeObject(products ?? new List()); + } +} diff --git a/EpicLoot/API/Version.cs b/EpicLoot/API/Version.cs new file mode 100644 index 000000000..101f34f94 --- /dev/null +++ b/EpicLoot/API/Version.cs @@ -0,0 +1,81 @@ +using JetBrains.Annotations; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace EpicLoot; + +public static partial class API +{ + /// + /// The version of the public API contract, independent of . + /// Bump this on every additive change; a consumer compares it to decide which endpoints exist. + /// + /// + /// 1 - Queries, providers (inventory / equipment / sacrifice), lifecycle events, loot generation, + /// item slot and icon decoration. + /// + [PublicAPI] + public const int ApiVersion = 1; + + /// , as a call so reflection consumers can read it without + /// resolving a const field (which would be inlined at their compile time, not ours). + [PublicAPI] + public static int GetApiVersion() + { + return ApiVersion; + } + + /// The running Epic Loot plugin version, e.g. "0.13.0". + [PublicAPI] + public static string GetPluginVersion() + { + return EpicLoot.Version; + } + + /// The Epic Loot plugin GUID, for BepInEx soft-dependency checks. + [PublicAPI] + public static string GetPluginId() + { + return EpicLoot.PluginId; + } + + /// + /// Feature probe. Lets a plugin built against a newer API degrade gracefully on an older Epic Loot + /// instead of throwing when a method it expects is missing. + /// + /// The exact, case-sensitive name of a public static method on this class. + /// true if at least one overload by that name exists + [PublicAPI] + public static bool HasEndpoint(string name) + { + if (string.IsNullOrEmpty(name)) + { + return false; + } + + return EndpointNames.Contains(name); + } + + /// Every public endpoint name on this class, for diagnostics. + [PublicAPI] + public static List GetEndpointNames() + { + return EndpointNames.ToList(); + } + + private static readonly HashSet EndpointNames = typeof(API) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Select(m => m.Name) + .ToHashSet(); + + /// + /// Turns on the API's internal registration/reload chatter. Off by default because a healthy + /// registration is not interesting; failures are logged unconditionally regardless of this flag. + /// + [PublicAPI] + public static void SetVerboseLogging(bool enabled) + { + ShowLogs = enabled; + } +} diff --git a/EpicLoot/API/Visuals.cs b/EpicLoot/API/Visuals.cs new file mode 100644 index 000000000..ebd8f205e --- /dev/null +++ b/EpicLoot/API/Visuals.cs @@ -0,0 +1,148 @@ +using JetBrains.Annotations; +using UnityEngine; +using UnityEngine.UI; + +namespace EpicLoot; + +/// +/// Hooks for plugins that draw their own item slots -- custom hotbars, extra equipment rows, quick +/// slots. Epic Loot decorates the vanilla inventory grid and hotkey bar with transpilers, so a plugin +/// that reimplements InventoryGrid.UpdateGui or HotkeyBar.UpdateIcons (a prefix returning +/// false, say) necessarily skips that decoration. This is the supported way to put it back. +/// +public static partial class API +{ + /// + /// Applies Epic Loot's rarity background to a single item slot, creating the child images the first + /// time one is actually needed. Safe to call every frame: after the first call only the sprite, + /// colour and enabled state are touched. + /// + /// The slot's root object -- the one holding "icon", "equiped" and friends. + /// The slot's "equiped" child. Used as the template for the images + /// this creates, and restyled to Epic Loot's equipped sprite. + /// The item in the slot. Null, or an item with no rarity, hides the background. + /// true for an inventory grid cell, which also gets the legendary set + /// marker; false for a hotbar element, which does not. + /// true if the slot was handled + [PublicAPI] + public static bool ApplyMagicItemBackground(GameObject slotRoot, GameObject equippedOverlay, + ItemDrop.ItemData item, bool inventoryGrid) + { + if (slotRoot == null || equippedOverlay == null) + { + return false; + } + + bool showBackground = item != null && item.UseMagicBackground(); + bool showSetMarker = inventoryGrid && item != null && item.IsSetItem(); + + // An empty slot has nothing to show and, until something has been shown in it, nothing to hide + // either -- so do not build images for every empty cell of every grid. + if (!showBackground && !showSetMarker && slotRoot.transform.Find("magicItem") == null) + { + return true; + } + + Image magicItem = ItemBackgroundHelper.CreateAndGetMagicItemBackgroundImage(slotRoot, equippedOverlay, inventoryGrid); + if (magicItem == null) + { + return false; + } + + magicItem.enabled = showBackground; + if (showBackground) + { + magicItem.sprite = EpicLoot.GetMagicItemBgSprite(); + magicItem.color = item.GetRarityColor(); + } + + if (!inventoryGrid) + { + return true; + } + + Transform setItemTransform = slotRoot.transform.Find("setItem"); + if (setItemTransform != null) + { + Image setItem = setItemTransform.GetComponent(); + if (setItem != null) + { + setItem.enabled = showSetMarker; + } + } + + return true; + } + + /// + /// Applies Epic Loot's rarity background behind an arbitrary item icon, for UI that is not a 64x64 + /// inventory cell -- crafting recipe rows, the crafting detail panel, custom lists. + /// assumes cell geometry and will not fit those. + /// + /// + /// The background is a clone of the icon, so it inherits the icon's anchors, pivot, size and + /// position and lines up whatever the layout. Safe to call every frame: after the first call only + /// the sprite, colour and enabled state are touched. + /// + /// The object holding the item's icon Image. Must have a parent -- + /// the background is created as its sibling. + /// The item the icon depicts. Null, or an item with no rarity, hides the + /// background. + /// true if the icon was handled + [PublicAPI] + public static bool ApplyMagicItemBackgroundToIcon(GameObject iconObject, ItemDrop.ItemData item) + { + if (iconObject == null || iconObject.transform.parent == null) + { + return false; + } + + Transform parent = iconObject.transform.parent; + Transform existing = parent.Find(IconBackgroundName); + bool showBackground = item != null && item.UseMagicBackground(); + + // Nothing to show and nothing built yet -- do not create an image per row of every list. + if (!showBackground && existing == null) + { + return true; + } + + Image background; + if (existing == null) + { + GameObject backgroundObject = UnityEngine.Object.Instantiate(iconObject, parent); + backgroundObject.name = IconBackgroundName; + backgroundObject.SetActive(true); + // Taking the icon's own index leaves the background drawn behind it. + backgroundObject.transform.SetSiblingIndex(iconObject.transform.GetSiblingIndex()); + + background = backgroundObject.GetComponent(); + if (background == null) + { + UnityEngine.Object.Destroy(backgroundObject); + return false; + } + + background.raycastTarget = false; + } + else + { + background = existing.GetComponent(); + if (background == null) + { + return false; + } + } + + background.enabled = showBackground; + if (showBackground) + { + background.sprite = EpicLoot.GetMagicItemBgSprite(); + background.color = item.GetRarityColor(); + } + + return true; + } + + private const string IconBackgroundName = "magicItemIconBG"; +} diff --git a/EpicLoot/CHANGELOG.md b/EpicLoot/CHANGELOG.md index 56dc0ad85..56e1af92f 100644 --- a/EpicLoot/CHANGELOG.md +++ b/EpicLoot/CHANGELOG.md @@ -1,3 +1,101 @@ +## Version 0.13.0 + +New Content: +* Shardstones Added! Shardstones are a new item that can be attached to weapons with available shardsockets + * Thanks Leslie, Warp and Rusty for contributing effects + * Interacting with an enchanted item that has a shard socket will pull up a small storage UI, allowing you to socket or remove shards. + * Shardstones can be found in the world as loot, different shards in each biome + * Every boss drops a shardstone of its own (Eikthyr, The Elder, Bonemass, Moder, Yagluth, The Queen and Fader), and two unique shardstones drop rarely from elite creatures and treasure chests + * 90+ new magic effects have been added and are primarily available through shardstones + * The enchanting table's probability panel now lists shard slot odds alongside effect count odds + * `Shard Stack Mode` option controls whether several shards of the same color may be socketed into the same item + * `Shard Removal Mode` option controls whether a socketed shard can be freely removed, must be broken out (destroying it), or is permanent + * `Shard Stone Drop Ratio` balance option weighs shardstone drops against normal item, unidentified and material drops +* Tempering (Thanks Rusty!) + * Tempering allows you to pay to increase the value of an existing enchantment (service provided by Hildir) + * Tempering can be configured to allow upgrading past a tiers normal maximum. + * Careful! Tempering can downgrade enchantments too if you critically fail (its configurable don't worry). +* Runestone Balance options + * Runestone extraction can now be configured with new options: KeepItem / ReduceEnchants / ReduceEnchantsAndRarity / DestroyItem + * Runestones can also be socketed into shardsockets +* Improved Tooltips! + * User-side configuration allows you to expand vertical or horizontally (or shrink) tooltips (scrollbar automatically used, as needed) + * SHIFT when moused over an item will now show extended tooltip information, this includes more detailed descriptions, configurable effects and precise values for multiple parts of the effects +* Terminal Commands upgraded (Thanks Rusty!) + * Terminal commands now have better completion and more options + * Color based formatting for rarity loot table rolls! +* Config option to show computed roll chance for all effects when enchanting an item (disabled by default) +* Client side config options to personalize how large item tooltips are +* Base configs now keep themselves up to date + * Epic Loot now tracks config file versions and will prompt you to update configs which have been customized + * Configs you *have* edited are never overwritten on their own. If an update changes one of them, a prompt at the main menu offers to replace it, copying your version to `BepInEx\config\EpicLoot\baseconfig-backup` first. Declining is remembered per file, and you are only asked again if that config changes in a future update + * Configs targeted by a patch file are unaffected, as they are already rebuilt from the current defaults every launch + * Dedicated servers log the affected files instead of prompting. + * Enabling `Always Refresh Core Configs` activates the patch system and prevents config file prompts (configs are rebuilt every restart, and only changes defined in patches will be applied) + +Changes: +* Enchanting shards (Shard Magic, Shard Rare, and so on -- not the new Shardstones) are now crafted at the enchanting table instead of the forge +* The Leather Belt, Silver Ring and Gold Ruby Ring are now configurable +* Enchanting table building resources are now easily configurable + * Resources to build the enchanting table has been changed to 10 wood, 2 greydwarf eyes +* The Epic Loot config file has been regrouped and now reads in a deliberate order instead of alphabetically, in both the file and the configuration manager + * Sections are numbered in the order they are meant to be read (`1 - General`, `2 - Balance`, `3 - Shardstones & Runes`, `4 - Enchanting Table`, `5 - Adventure`, `6 - Interface`, `7 - Item Colors`, `8 - Abilities`, `9 - Debug`) and the settings inside each one keep the order they were written in + * The bounty and adventure settings gathered into Adventure, the crafting UI / tooltip / panel position settings into Interface, `Rune Extract Mode` into Shardstones & Runes, and logging into Debug + * Every setting you had customized is carried over to its new home automatically; nothing resets to defaults + * Each setting's description now states whether it is synced from the server +* New cheat commands `magicshards` and `cheatsockets` + +Bugfixes: +* Fixes tooltip display for enchanted items health use percentage +* Fixes a bug with Ragnar's ice wave which would cause a player to swim upwards in the air (upon exit) if the effect was used in a dungeon +* Attack speed compatibility: when another mod already provides AnimationSpeedManager (embedded as a binary), Epic Loot now routes its attack-speed changes into that shared instance instead of running its own copy, so the two mods' attack-speed modifiers stack correctly instead of overwriting each other +* Merchant panels are now draggable, and save their position client side +* Config patches now honor the `Priority` field reliably and equal-priority patches are now applied in a deterministic order instead of depending on the filesystem's file listing order + * When any config patches are active, Epic Loot now always logs a one-line summary of how many patches were applied (regardless of your logging settings) to make patched setups obvious in bug reports +* Adventure data (Treasure hunts, bounties etc) is now compressed and cleaned resulting in massive reductions in stored custom data +* Transfer Enchantments through crafting now works properly +* Chest loot is now rolled when it is opened, not when it is spawned, so that the loot is always appropriate for the player opening it. This primarily fixes mountain & swamp loot which would be loaded very early, but actually opened later. +* Filtering an enchanting table list no longer hides items from you while still acting on them. Previously, typing in the Sacrifice tab's filter and pressing Select All would sacrifice every sacrificeable item in your inventory, not just the filtered ones + * Select All, the products preview and the action itself now only ever cover the rows the filter is showing + * Items you selected before filtering keep their selection, so you can filter and select several times over and then clear the filter to act on everything you picked + * The filter is also reapplied after each action and when switching between Sacrifice and Identify, so the list no longer shows a stale set of rows +* Equip effect visuals (auras such as `Glowing`) are now reconciled against your equipped magic gear after every equipment change, instead of being attached from the model-building path and removed from the unequip path + * Items worn in slots added by other mods now get their aura, which previously never appeared at all + * The spurious `Unequipped item (...) from player that had fx, but could not find fx (...)!` error is gone — a missing effect is now simply nothing to remove + * An aura shared by two worn items survives unequipping either one, and is removed when the last one comes off +* Fixed the reset for helmet attachment never running, which let the helmet's texture overrides and item effects be applied to a beard, hair or back-slung weapon model instead +* Fixed legendary texture overrides on armor being resolved by item type rather than by the item actually being attached, which picked the wrong item when two worn items shared a type + * This also makes legendary armor texture overrides render for other players, which previously never happened + +API: +* The API now covers integration, not just content registration. Full reference and a migration table in `EpicLoot/docs/API.md` + * `API.ApiVersion` / `GetApiVersion()` / `HasEndpoint(name)` let a mod check what the installed Epic Loot supports and degrade gracefully instead of throwing + * The `EpicLootAPI` shim provides typed wrappers for everything below and can be bundled into your plugin with ILRepack +* Inventory providers — `API.RegisterInventoryProvider` lets container, backpack and stash mods feed the enchanting table + * Epic Loot spends the player's own inventory first and only charges the shortfall to providers + * Removal by item instance matches on reference, so magic data on the consumed item is preserved +* Equipment providers — `API.RegisterEquipmentProvider` lets extra-slot mods contribute equipped items + * Contributed items count toward effect totals, legendary set bonuses, tooltips and shard socketing, and receive their equip effect visuals + * `API.InvalidatePlayerEffectCache(player)` discards memoized effect totals and refreshes worn equip effect visuals when your slot contents change outside vanilla equip/unequip +* Sacrifice filters — `API.RegisterSacrificeFilter` lets a mod veto sacrificing an item, so gear equipped in slots Epic Loot cannot see is not destroyed by accident +* Custom item slots — `API.ApplyMagicItemBackground` draws the rarity background, set marker and equipped overlay on a slot your own mod renders + * Epic Loot decorates the vanilla grid and hotkey bar with transpilers, so a mod that reimplements `InventoryGrid.UpdateGui` or `HotkeyBar.UpdateIcons` previously lost the background entirely; this is the supported way to put it back +* Lifecycle events — `API.AddMagicItemChangedListener`, `AddLootGeneratedListener` and `AddBountyCompletedListener` + * The change event fires on every magic-data write with a reason token: `Enchant`, `Augment`, `Disenchant`, `Rune`, `Temper`, `Socket`, `Unsocket`, `LootRoll`, `Transfer`, or `Unspecified`. Item loading does not raise it +* Queries — `IsMagicItem`, `TryGetRarity` (non-throwing), rarity colors and display names, `IsEpicLootItem`, `IsShardStone`, `IsRunestone`, `IsMagicCraftingMaterial`, `IsUnidentified`, `CanBeMagicItem`, `ItemHasMagicEffect`, `GetAllMagicEffectTypes`, `GetEnchantCostsJson`, `GetSacrificeProductsJson` +* Loot generation — `TryMakeMagicItem` rolls and applies a magic item exactly the way a drop does, plus `RollMagicItemJson` / `ApplyMagicItemJson` to inspect before applying, `GetLuckFactor`, `RollEffectCountForRarity`, `GetLegendaryIDs` and `GetLegendaryInfoJson` + * `API.AddLootTables` / `UpdateLootTables` register loot tables at runtime, and they are re-applied on config reload and on a dedicated server's config push +* New `magicapi` console command (`version`, `query`, `providers`, `events`, `roll`) for testing an integration in game +* Mod authors patching `PlayerExtensions.GetEquipment` should switch to an equipment provider: that method is a deprecated alias and nothing inside Epic Loot calls it +* `EpicLootAPI`'s `MagicItem` was updated to account for tempering, shardstones and unidentified items +* New magic effect requirement flags: `ItemGivesAdrenaline` (item's attack grants above-default adrenaline) and `ItemHasAdrenaline` (item provides a max adrenaline pool) + * Note: `ItemGivesAdrenaline` requires that the item gives more than 1 adrenaline. This is due to all items by default adding 1 adrenaline. +* New magic effect requirements allowed from the API + * API magic effect requirements when defined for an effect will be evaluated for all items, allowing only items which fit the filter (Thanks Warp!) +* `recipes.json` has been removed (its contained recipes are now handled and directly exposed in the Bepinex config file [Randyknapp.epicloot.cfg]) + * Mod authors: `API.AddRecipe`, `API.AddRecipes`, `API.UpdateRecipes` and the shim's `CustomRecipe` are now deprecated. + + ## Version 0.12.15 * Fixed an issue with bounties not spawning correctly if not using the Star Level System mod. diff --git a/EpicLoot/EpicLoot-Header.psd b/EpicLoot/EpicLoot-Header.psd new file mode 100644 index 000000000..3035b71e7 Binary files /dev/null and b/EpicLoot/EpicLoot-Header.psd differ diff --git a/EpicLoot/EpicLoot-Tile.psd b/EpicLoot/EpicLoot-Tile.psd new file mode 100644 index 000000000..baa6f5185 Binary files /dev/null and b/EpicLoot/EpicLoot-Tile.psd differ diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index 3f69a2a8d..216aa5163 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -1,4 +1,4 @@ -using BepInEx; +using BepInEx; using Common; using EpicLoot.Adventure; using EpicLoot.Config; @@ -8,6 +8,7 @@ using EpicLoot.GatedItemType; using EpicLoot.General; using EpicLoot.Magic; +using EpicLoot.Magic.MagicItemEffects.Helpers; using EpicLoot.MagicItemEffects; using HarmonyLib; using JetBrains.Annotations; @@ -33,11 +34,10 @@ namespace EpicLoot; [BepInDependency("vapok.mods.adventurebackpacks", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("kg.ValheimEnchantmentSystem", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("org.bepinex.plugins.steadyregeneration", BepInDependency.DependencyFlags.SoftDependency)] -public sealed class EpicLoot : BaseUnityPlugin -{ +public sealed class EpicLoot : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.epicloot"; public const string DisplayName = "Epic Loot"; - public const string Version = "0.12.15"; + public const string Version = "0.13.0"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; @@ -66,19 +66,20 @@ public sealed class EpicLoot : BaseUnityPlugin "Essence" }; + /// + /// Plain, non-craftable items registered straight from the bundle. Craftable items are declared in + /// instead, so their recipe and station are config driven. + /// public static string[] ItemNames = new string[] { - "LeatherBelt", - "SilverRing", - "GoldRubyRing", "ForestToken", "IronBountyToken", "GoldBountyToken" }; public static bool AlwaysDropCheat = false; - public const Minimap.PinType BountyPinType = (Minimap.PinType) 800; - public const Minimap.PinType TreasureMapPinType = (Minimap.PinType) 801; + public const Minimap.PinType BountyPinType = (Minimap.PinType)800; + public const Minimap.PinType TreasureMapPinType = (Minimap.PinType)801; public static bool HasAuga; public static bool AugaTooltipNoTextBoxes; @@ -91,13 +92,12 @@ public sealed class EpicLoot : BaseUnityPlugin internal ELConfig cfg; [UsedImplicitly] - void Awake() - { + void Awake() { _instance = this; - Assembly assembly = Assembly.GetExecutingAssembly(); - - LoadEmbeddedAssembly(assembly, "EpicLoot-UnityLib.dll"); + // Wire the shared Common support layer (config binder, piece loader, drawers) to this plugin before + // any config is bound, so ConfigBinder and ModLogger have a config file and log source to use. + ModContext.Initialize(this, Logger, "EpicLoot"); cfg = new ELConfig(Config); @@ -106,40 +106,68 @@ void Awake() InitializeAbilities(); AddLocalizations(); LoadAssets(); - EnchantingUIController.Initialize(); _harmony = Harmony.CreateAndPatchAll(Assembly.GetExecutingAssembly(), PluginId); LootTableLoaded?.Invoke(); RegisterMagicEffectEvents(); - TerminalCommands.AddTerminalCommands(); + // Scheduled (non-per-frame) effect drivers, on their own DontDestroyOnLoad objects. + MagicItemEffects.Shards.PoisonAdrenalinePulse.Create(); + MagicItemEffects.Shards.StormFuryPulse.Create(); + // Main file config watcher SetupWatcher(); } - private static void RegisterMagicEffectEvents() - { + private static void RegisterMagicEffectEvents() { + // Register per-effect tooltip display-value providers (effects that show more than one number). + // These are keyed by effect-type constant and don't depend on config load, so register once here. + MagicItemEffects.BulkupEffect.RegisterDisplayValues(); + MagicItemEffects.Shards.HealthGainPerXDamageDone.RegisterDisplayValues(); + MagicItemEffects.Shards.HealthOnEitrUse.RegisterDisplayValues(); + MagicItemEffects.Shards.Mercenary.RegisterDisplayValues(); + MagicItemEffects.Shards.Coinplated.RegisterDisplayValues(); + MagicItemEffects.Shards.Wager.RegisterDisplayValues(); + MagicItemEffects.Shards.ChanceToCritOnHit.RegisterDisplayValues(); + MagicItemEffects.Shards.BloodDrinker.RegisterDisplayValues(); + MagicItemEffects.Shards.TravelLight.RegisterDisplayValues(); + MagicItemEffects.Shards.PerfectDodge.RegisterDisplayValues(); + MagicItemEffects.Shards.AdrenalineFrostWave.RegisterDisplayValues(); + MagicItemEffects.Shards.AdrenalineIncreasesHealthRegen.RegisterDisplayValues(); + MagicItemEffects.Shards.GainAdrenalineWhenApplyingPoison.RegisterDisplayValues(); + MagicItemEffects.Shards.StormFury.RegisterDisplayValues(); + MagicItemEffects.Shards.LuckWhileFishing.RegisterDisplayValues(); + MagicItemEffects.Shards.Kindling.RegisterDisplayValues(); + MagicItemEffects.Shards.Conduit.RegisterDisplayValues(); + MagicItemEffects.Shards.Inspiration.RegisterDisplayValues(); + MagicItemEffects.Shards.LuckyLoot.RegisterDisplayValues(); + MagicItemEffects.Shards.Bloodrage.RegisterDisplayValues(); + MagicItemEffects.Shards.MeteorSummoner.RegisterDisplayValues(); + MagicItemEffects.Shards.BlockAsDodgeAsBlock.RegisterDisplayValues(); + MagicItemEffects.Shards.BlockAsWoodCuttingAndPickaxes.RegisterDisplayValues(); + // This needs to not run until after the game is loaded, otherwise it will not be able to find the ObjectDB MagicItemEffectDefinitions.OnSetupMagicItemEffectDefinitions += Riches_CharacterDrop_GenerateDropList_Patch.UpdateRichesOnEffectSetup; - } - private static void LoadEmbeddedAssembly(Assembly assembly, string assemblyName) - { - var stream = assembly.GetManifestResourceStream($"{assembly.GetName().Name}.{assemblyName}"); - if (stream == null) - { - LogErrorForce($"Could not load embedded assembly ({assemblyName})!"); - return; + // Register definitions for the Shardstone-only effect types (blank tooltips + warnings otherwise). + // Re-runs on every config (re)load; the defensive call covers the case where the effect config was + // already loaded (during ELConfig construction) before this subscription was added. + MagicItemEffectDefinitions.OnSetupMagicItemEffectDefinitions += ShardEffectDefinitions.RegisterShardEffectDefinitions; + if (MagicItemEffectDefinitions.AllDefinitions.Count > 0) { + ShardEffectDefinitions.RegisterShardEffectDefinitions(); } - using (stream) - { - var data = new byte[stream.Length]; - stream.Read(data, 0, data.Length); - Assembly.Load(data); + // Fold the ShardStone recipes into the material conversions (enchanting "Convert Materials" tab). + // Same event/defensive pattern as the shard effect definitions above: re-runs on every + // material-conversions (re)load, with a defensive call if that config was already loaded before + // this subscription was added. + CraftingV2.MaterialConversions.OnSetupMaterialConversions += ShardStones.ShardStoneConversions.Merge; + if (CraftingV2.MaterialConversions.Config != null) { + ShardStones.ShardStoneConversions.Merge(); } } + //sealed void Start() //{ // //HasAuga = Auga.API.IsLoaded(); @@ -236,10 +264,10 @@ private static void LoadEmbeddedAssembly(Assembly assembly, string assemblyName) // { // //Auga.API.ComplexTooltip_SetSubtitle(complexTooltip, localizedSubtitle); // } - + // if (AugaTooltipNoTextBoxes) // return; - + // //Don't need to process the InventoryTooltip Information. // if (complexTooltip.name.Contains("InventoryTooltip")) // return; @@ -251,13 +279,13 @@ private static void LoadEmbeddedAssembly(Assembly assembly, string assemblyName) // var textBox = Auga.API.ComplexTooltip_AddTwoColumnTextBox(complexTooltip); // magicItemText = magicItemText.Replace("\n\n", ""); // Auga.API.TooltipTextBox_AddLine(textBox, magicItemText); - + // if (magicItem.IsLegendarySetItem()) // { // var textBox2 = Auga.API.ComplexTooltip_AddTwoColumnTextBox(complexTooltip); // Auga.API.TooltipTextBox_AddLine(textBox2, item.GetSetTooltip()); // } - + // try // { // Auga.API.ComplexTooltip_SetDescription(complexTooltip, @@ -270,13 +298,11 @@ private static void LoadEmbeddedAssembly(Assembly assembly, string assemblyName) // } //} - private void AddLocalizations() - { + private void AddLocalizations() { CustomLocalization Localization = LocalizationManager.Instance.GetLocalization(); // load all localization files within the localizations directory Log("Loading Localizations."); - foreach (string embeddedResouce in typeof(EpicLoot).Assembly.GetManifestResourceNames()) - { + foreach (string embeddedResouce in typeof(EpicLoot).Assembly.GetManifestResourceNames()) { if (!embeddedResouce.Contains("localizations")) { continue; } string localization = ReadEmbeddedResourceFile(embeddedResouce); // This will clean comments out of the localization files @@ -290,57 +316,77 @@ private void AddLocalizations() ELConfig.StartupProcessModifiedLocalizations(); } - private static void InitializeAbilities() - { + private static void InitializeAbilities() { MagicEffectType.Initialize(); AbilitiesInitialized?.Invoke(); } - public static void Log(string message) - { - if (ELConfig._loggingEnabled.Value && ELConfig._logLevel.Value <= LogLevel.Info) - { - _instance.Logger.LogInfo(message); + private static BepInEx.Logging.ManualLogSource _fallbackLogSink; + + /// + /// Where every Log* below writes. Falls back to a standalone log source when + /// is null or has been destroyed, so logging survives outside the plugin's own lifetime. + /// + private static BepInEx.Logging.ManualLogSource LogSink => + _instance != null + ? _instance.Logger + : _fallbackLogSink ??= BepInEx.Logging.Logger.CreateLogSource(DisplayName); + + /// + /// Gate for the level-filtered Log* helpers. Both config entries are bound in + /// ELConfig.CreateConfigValues, but these helpers run from Harmony prefixes on hot vanilla methods + /// (Inventory.AddItem among them), so an unguarded deref here does not merely lose a log line - it + /// throws out of the vanilla method that was running. An unbound config logs rather than staying + /// silent, since that state is itself worth seeing. + /// + private static bool ShouldLog(LogLevel level) { + BepInEx.Configuration.ConfigEntry enabled = ELConfig._loggingEnabled; + BepInEx.Configuration.ConfigEntry threshold = ELConfig._logLevel; + return enabled == null || threshold == null || (enabled.Value && threshold.Value <= level); + } + + public static void Log(string message) { + if (ShouldLog(LogLevel.Info)) { + LogSink.LogInfo(message); } } - public static void LogWarning(string message) - { - if (ELConfig._loggingEnabled.Value && ELConfig._logLevel.Value <= LogLevel.Warning) - { - _instance.Logger.LogWarning(message); + public static void LogWarning(string message) { + if (ShouldLog(LogLevel.Warning)) { + LogSink.LogWarning(message); } } - public static void LogError(string message) - { - if (ELConfig._loggingEnabled.Value && ELConfig._logLevel.Value <= LogLevel.Error) - { - _instance.Logger.LogError(message); + public static void LogError(string message) { + if (ShouldLog(LogLevel.Error)) { + LogSink.LogError(message); } } - public static void LogWarningForce(string message) - { - _instance.Logger.LogWarning(message); + public static void LogForce(string message) { + // Intentionally NOT gated by _loggingEnabled/_logLevel: some diagnostics must always be visible. + LogSink.LogInfo(message); } - public static void LogErrorForce(string message) - { - _instance.Logger.LogError(message); + public static void LogWarningForce(string message) { + LogSink.LogWarning(message); } - private static void LoadAssets() - { + public static void LogErrorForce(string message) { + LogSink.LogError(message); + } + + private static void LoadAssets() { var assetBundle = LoadAssetBundle("epicloot"); - if (assetBundle == null) - { + if (assetBundle == null) { LogErrorForce("Unable to load asset bundle! This mod will not behave as expected!"); return; } EpicAssets.AssetBundle = assetBundle; + // Shared Common loaders (PieceLoader) resolve their prefabs through here. + ModContext.AssetBundle = assetBundle; EpicAssets.EquippedSprite = assetBundle.LoadAsset("Equipped"); EpicAssets.AugaEquippedSprite = assetBundle.LoadAsset("AugaEquipped"); EpicAssets.GenericSetItemSprite = assetBundle.LoadAsset("GenericSetItemMarker"); @@ -364,6 +410,8 @@ private static void LoadAssets() EpicAssets.AugmentItemSFX = assetBundle.LoadAsset("AugmentItem"); EpicAssets.MerchantPanel = assetBundle.LoadAsset("MerchantPanel"); + EpicAssets.TemperPanel = assetBundle.LoadAsset("TemperPanel"); + EpicAssets.MapIconTreasureMap = assetBundle.LoadAsset("TreasureMapIcon"); EpicAssets.MapIconBounty = assetBundle.LoadAsset("MapIconBounty"); EpicAssets.AbandonBountySFX = assetBundle.LoadAsset("AbandonBounty"); @@ -372,6 +420,13 @@ private static void LoadAssets() EpicAssets.DebugTextPrefab = assetBundle.LoadAsset("DebugText"); EpicAssets.AbilityBar = assetBundle.LoadAsset("AbilityBar"); EpicAssets.WelcomMessagePrefab = assetBundle.LoadAsset("WelcomeMessage"); + EpicAssets.ConfigMessagePrefab = assetBundle.LoadAsset("ConfigMessage"); + EpicAssets.SocketMessagePrefab = assetBundle.LoadAsset("SocketMessage"); + + // Register the frost-cone AOE effect (Ragnar set / FrostDamageAOE) so its ZNetView networks + // properly, and route its ripped SFX through the volume mixer once AudioMan exists (below). + EpicAssets.IceSpikesVFX = assetBundle.LoadAsset(FrostAOE.Attack_DoMeleeAttack_Transpiler.FxPrefabName); + PrefabManager.Instance.AddPrefab(new CustomPrefab(EpicAssets.IceSpikesVFX, false)); EpicAssets.BulwarkStatusEffect = assetBundle.LoadAsset(EpicAssets.Bulwark_SE_Name); EpicAssets.BulwarkMagicShieldVFX = assetBundle.LoadAsset("MagicShield"); @@ -383,7 +438,7 @@ private static void LoadAssets() EpicAssets.BerserkerStatusEffect = assetBundle.LoadAsset(EpicAssets.Berserker_SE_Name); EpicAssets.BerserkerVFX = assetBundle.LoadAsset("Berserker"); - EpicAssets.BerserkerSFX = assetBundle.LoadAsset("sfx_berserker"); + EpicAssets.BerserkerSFX = assetBundle.LoadAsset("sfx_berserker"); EpicAssets.DodgeBuffStatusEffect = assetBundle.LoadAsset(EpicAssets.DodgeBuff_SE_Name); EpicAssets.DodgeBuffSFX = assetBundle.LoadAsset("sfx_dodgebuff"); @@ -399,80 +454,97 @@ private static void LoadAssets() RegisterStatusEffects(); PrefabManager.OnPrefabsRegistered += SetupAndvaranaut; + // Runs during ZNetScene setup (after IceSpikes is registered, while AudioMan exists) so the + // frost-cone SFX is routed through the volume mixer instead of playing at full volume. + PrefabManager.OnPrefabsRegistered += FrostAOE.HookUpIceSpikesAudio; + // Registers our player-faction, tamed clone of the vanilla 'Bat' into ZNetScene on every client each + // world load (fires as a ZNetScene.Awake postfix), so the SummonBat trinket shard can spawn a + // reload-safe pet that stays friendly. Idempotent -- built once and re-injected each ZNetScene. + PrefabManager.OnPrefabsRegistered += MagicItemEffects.Shards.SummonBatWhenActivatingAdrenaline.RegisterTamedBatPrefab; + // The Stormcaller and Firewalker unique shards carry their whole effect in a prefab clone built at + // ZNetScene setup: a damage-free copy of the vanilla lightning AOE, and the burning patch the fire + // trail drops. Without these two lines each effect logs a missing-prefab warning once and then does + // nothing at all, so they are load-bearing, not cosmetic. + PrefabManager.OnPrefabsRegistered += MagicItemEffects.Shards.StrikeCausesLightning.RegisterVisualPrefab; + PrefabManager.OnPrefabsRegistered += MagicItemEffects.Shards.Trailblazer.RegisterVfxPrefab; ItemManager.OnItemsRegistered += SetupStatusEffects; LoadUnidentifiedItems(); + ShardStones.Shards.CreateAndLoadShardItems(); // Needs to trigger late in order to get all potentially added items by other mods MinimapManager.OnVanillaMapDataLoaded += () => AutoAddEnchantableItems.CheckAndAddAllEnchantableItems(); EpicAssets.AssertAssetIntegrety(); } - public static T LoadAsset(string assetName) where T : Object - { - try - { - if (EpicAssets.AssetCache.ContainsKey(assetName)) - { + public static T LoadAsset(string assetName) where T : Object { + try { + if (EpicAssets.AssetCache.ContainsKey(assetName)) { return (T)EpicAssets.AssetCache[assetName]; } var asset = EpicAssets.AssetBundle.LoadAsset(assetName); EpicAssets.AssetCache.Add(assetName, asset); return asset; - } - catch (Exception e) - { + } catch (Exception e) { LogErrorForce($"Error loading asset ({assetName}): {e.Message}"); return null; } } - private static void LoadPieces() - { - GameObject enchanter = EpicAssets.AssetBundle.LoadAsset("piece_enchanter"); - PieceConfig enchanterPC = new PieceConfig(); - enchanterPC.PieceTable = "Hammer"; - enchanterPC.Category = PieceCategories.Misc; - enchanterPC.AllowedInDungeons = false; - enchanterPC.Requirements = new RequirementConfig[] - { - new RequirementConfig() { Item = "Stone", Amount = 10, Recover = true }, - new RequirementConfig() { Item = "SurtlingCore", Amount = 3, Recover = true }, - new RequirementConfig() { Item = "Copper", Amount = 3, Recover = true }, - new RequirementConfig() { Item = "SwordCheat", Amount = 1, Recover = false } - }; - PieceManager.Instance.AddPiece(new CustomPiece(enchanter, true, enchanterPC)); - - GameObject augmenter = EpicAssets.AssetBundle.LoadAsset("piece_augmenter"); - PieceConfig augmenterPC = new PieceConfig(); - augmenterPC.PieceTable = "Hammer"; - augmenterPC.Category = PieceCategories.Misc; - augmenterPC.AllowedInDungeons = false; - augmenterPC.Requirements = new RequirementConfig[] - { - new RequirementConfig() { Item = "Obsidian", Amount = 10, Recover = true }, - new RequirementConfig() { Item = "Crystal", Amount = 3, Recover = true }, - new RequirementConfig() { Item = "Bronze", Amount = 3, Recover = true }, - new RequirementConfig() { Item = "SwordCheat", Amount = 1, Recover = false } - }; - PieceManager.Instance.AddPiece(new CustomPiece(augmenter, true, augmenterPC)); - - GameObject table = EpicAssets.AssetBundle.LoadAsset("piece_enchantingtable"); - PieceConfig tablePC = new PieceConfig(); - tablePC.PieceTable = "Hammer"; - tablePC.Category = PieceCategories.Crafting; - tablePC.Requirements = new RequirementConfig[] - { - new RequirementConfig() { Item = "FineWood", Amount = 10, Recover = true }, - new RequirementConfig() { Item = "SurtlingCore", Amount = 1, Recover = true } - }; - PieceManager.Instance.AddPiece(new CustomPiece(table, true, tablePC)); + /// + /// Registers the build pieces through the shared , which exposes each piece's + /// build cost, category and workbench requirement as server-synced config entries that apply live. + /// None of these pieces required a crafting station before, hence RequiresWorkbench = false. + /// + private static void LoadPieces() { + PieceLoader.Register(new PieceLoader.BuildPiece { + Name = "Enchanter", + Prefab = "piece_enchanter", + Category = PieceCategories.Misc, + RequiresWorkbench = false, + AllowedInDungeons = false, + Enabled = false, + PieceCost = new List + { + new PieceLoader.PieceCost { Prefab = "Stone", Amount = 10, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "SurtlingCore", Amount = 3, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "Copper", Amount = 3, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "SwordCheat", Amount = 1, Refundable = false } + } + }); + + PieceLoader.Register(new PieceLoader.BuildPiece { + Name = "Augmenter", + Prefab = "piece_augmenter", + Category = PieceCategories.Misc, + RequiresWorkbench = false, + AllowedInDungeons = false, + Enabled = false, + PieceCost = new List + { + new PieceLoader.PieceCost { Prefab = "Obsidian", Amount = 10, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "Crystal", Amount = 3, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "Bronze", Amount = 3, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "SwordCheat", Amount = 1, Refundable = false } + } + }); + + PieceLoader.Register(new PieceLoader.BuildPiece { + Name = "Enchanting Table", + Prefab = "piece_enchantingtable", + Category = PieceCategories.Crafting, + RequiresWorkbench = false, + AllowedInDungeons = false, + PieceCost = new List + { + new PieceLoader.PieceCost { Prefab = "Wood", Amount = 10, Refundable = true }, + new PieceLoader.PieceCost { Prefab = "GreydwarfEye", Amount = 2, Refundable = true }, + } + }); } - private static void LoadItems() - { - foreach (var item in ItemNames) - { + private static void LoadItems() { + foreach (var item in ItemNames) { var go = EpicAssets.AssetBundle.LoadAsset(item); var customItem = new CustomItem(go, false); ItemManager.Instance.AddItem(customItem); @@ -485,43 +557,99 @@ private static void LoadItems() itemDrop.m_itemData.m_shared.m_name = ""; var dummyItem = new CustomItem(dummyGO, false); ItemManager.Instance.AddItem(dummyItem); + + LoadCraftableItems(); } - private static void LoadBountySpawner() - { + /// + /// Registers the craftable items through the shared , which gives each one + /// server-synced config entries for its recipe, crafting station, station level and craft amount, all + /// applying live without a restart. + /// + private static void LoadCraftableItems() { + // EpicLoot's bundle stores assets under bare names, and these prefabs are authored against the real + // game assets (no JVLmock_ references to resolve) and carry their own icons. + ItemBatchLoader.PrefabPathFormat = null; + ItemBatchLoader.IconPathFormat = null; + ItemBatchLoader.FixReferences = false; + + var loader = new ItemBatchLoader(); + + loader.AddDefinition(new ItemDefinition { + Name = "Leather Belt", + Prefab = "LeatherBelt", + Category = ItemCategory.Misc, + CraftedAt = "forge", + ReqStationlevel = 1, + CraftAmount = 1, + Recipe = new RecipeDefinition { + RecipeItems = new List + { + new RecipeIngredient { Prefab = "LeatherScraps", Amount = 4 }, + new RecipeIngredient { Prefab = "Bronze", Amount = 1 } + } + } + }); + + loader.AddDefinition(new ItemDefinition { + Name = "Silver Ring", + Prefab = "SilverRing", + Category = ItemCategory.Misc, + CraftedAt = "forge", + ReqStationlevel = 1, + CraftAmount = 1, + Recipe = new RecipeDefinition { + RecipeItems = new List + { + new RecipeIngredient { Prefab = "Silver", Amount = 1 } + } + } + }); + + loader.AddDefinition(new ItemDefinition { + Name = "Gold Ruby Ring", + Prefab = "GoldRubyRing", + Category = ItemCategory.Misc, + CraftedAt = "forge", + ReqStationlevel = 1, + CraftAmount = 1, + Recipe = new RecipeDefinition { + RecipeItems = new List + { + new RecipeIngredient { Prefab = "Coins", Amount = 200 }, + new RecipeIngredient { Prefab = "Ruby", Amount = 1 } + } + } + }); + + loader.BatchSetup(); + } + + private static void LoadBountySpawner() { GameObject bounty_spawner = EpicAssets.AssetBundle.LoadAsset("EL_SpawnController"); - if (bounty_spawner == null) - { + if (bounty_spawner == null) { LogErrorForce("Unable to find bounty spawner asset! This mod will not behave as expected!"); - } - else - { + } else { bounty_spawner.AddComponent(); CustomPrefab prefab_obj = new CustomPrefab(bounty_spawner, false); PrefabManager.Instance.AddPrefab(prefab_obj); } } - private static void LoadCraftingMaterialAssets() - { - foreach (string type in MagicMaterials) - { - foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity))) - { + private static void LoadCraftingMaterialAssets() { + foreach (string type in MagicMaterials) { + foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity))) { string assetName = $"{type}{rarity}"; GameObject prefab = EpicAssets.AssetBundle.LoadAsset(assetName); - if (!prefab) - { + if (!prefab) { LogErrorForce($"Tried to load asset {assetName} but it does not exist in the asset bundle!"); continue; } - if (prefab.TryGetComponent(out ItemDrop itemDrop)) - { - if (itemDrop.m_itemData.IsMagicCraftingMaterial()) - { + if (prefab.TryGetComponent(out ItemDrop itemDrop)) { + if (itemDrop.m_itemData.IsMagicCraftingMaterial()) { // Set icons for crafting materials. itemDrop.m_itemData.m_variant = GetRarityIconIndex(rarity); } @@ -536,38 +664,35 @@ private static void LoadCraftingMaterialAssets() } } - private static void LoadUnidentifiedItems() - { + private static void LoadUnidentifiedItems() { // TODO: Add support for biomes added by other mods as needed. GameObject genericPrefab = EpicAssets.AssetBundle.LoadAsset("_Unidentified"); CustomItem genericUnidentified = new CustomItem(genericPrefab, false); ItemManager.Instance.AddItem(genericUnidentified); genericPrefab.SetActive(false); - foreach (string biome in Enum.GetNames(typeof(Heightmap.Biome))) - { - if (biome == "None" || biome == "All") - { + var unidentifiedPrefabNames = new List(); + + foreach (string biome in Enum.GetNames(typeof(Heightmap.Biome))) { + if (biome == "None" || biome == "All") { continue; } - foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity))) - { + foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity))) { var prefab = Object.Instantiate(genericPrefab); string prefabName = $"{biome}_{rarity}_Unidentified"; prefab.name = prefabName; ItemDrop pid = prefab.GetComponent(); var magicItemComponent = pid.m_itemData.Data().GetOrCreate(); pid.m_itemData.m_dropPrefab = prefab; - magicItemComponent.SetMagicItem(new MagicItem - { + magicItemComponent.SetMagicItem(new MagicItem { Rarity = rarity, IsUnidentified = true, }); magicItemComponent.Save(); - - ItemConfig unidentifiedIC = new ItemConfig() - { + pid.Save(); + + ItemConfig unidentifiedIC = new ItemConfig() { Name = $"$mod_epicloot_{rarity} $mod_epicloot_unidentified_{biome}", Description = "$mod_epicloot_unidentified_introduce", }; @@ -575,71 +700,72 @@ private static void LoadUnidentifiedItems() CustomItem custom = new CustomItem(prefab, false, unidentifiedIC); ItemManager.Instance.AddItem(custom); - // Enable Items once things are working so that ZNet issues don't happen - void EnableUnidentified(string prefabname) - { - PrefabManager.Instance.GetPrefab(prefabName).SetActive(true); - PrefabManager.Instance.GetPrefab(prefabName).GetComponent().m_itemData.m_dropPrefab = - PrefabManager.Instance.GetPrefab(prefabName); - ItemManager.OnItemsRegistered -= () => EnableUnidentified(prefabname); + unidentifiedPrefabNames.Add(prefabName); + } + } + + // Enable items once things are working so that ZNet issues don't happen. + // A single idempotent handler activates every registered prefab; a null + // lookup logs and continues so one missing prefab can't leave the rest inactive. + void EnableUnidentifiedItems() { + foreach (string prefabName in unidentifiedPrefabNames) { + GameObject prefab = PrefabManager.Instance.GetPrefab(prefabName); + if (prefab == null) { + LogError($"Could not find unidentified prefab '{prefabName}' to activate."); + continue; } - ItemManager.OnItemsRegistered += () => EnableUnidentified(prefabName); + prefab.SetActive(true); + prefab.GetComponent().m_itemData.m_dropPrefab = prefab; } } + + ItemManager.OnItemsRegistered += EnableUnidentifiedItems; } - private static void RegisterStatusEffects() - { + private static void RegisterStatusEffects() { RegisterBulwark(); RegisterUndying(); RegisterBerserker(); RegisterAdrenalineRush(); } - private static void RegisterBulwark() - { + private static void RegisterBulwark() { PrefabManager.Instance.AddPrefab(EpicAssets.BulwarkMagicShieldVFX); PrefabManager.Instance.AddPrefab(EpicAssets.BulwarkMagicShieldSFX); ItemManager.OnItemsRegistered += () => ObjectDB.instance.m_StatusEffects.Add(EpicAssets.BulwarkStatusEffect); } - private static void RegisterBerserker() - { + private static void RegisterBerserker() { PrefabManager.Instance.AddPrefab(EpicAssets.BerserkerVFX); PrefabManager.Instance.AddPrefab(EpicAssets.BerserkerSFX); ItemManager.OnItemsRegistered += () => ObjectDB.instance.m_StatusEffects.Add(EpicAssets.BerserkerStatusEffect); } - private static void RegisterUndying() - { + private static void RegisterUndying() { PrefabManager.Instance.AddPrefab(EpicAssets.UndyingVFX); PrefabManager.Instance.AddPrefab(EpicAssets.UndyingSFX); ItemManager.OnItemsRegistered += () => ObjectDB.instance.m_StatusEffects.Add(EpicAssets.UndyingStatusEffect); } - private static void RegisterAdrenalineRush() - { + private static void RegisterAdrenalineRush() { PrefabManager.Instance.AddPrefab(EpicAssets.DodgeBuffSFX); ItemManager.OnItemsRegistered += () => ObjectDB.instance.m_StatusEffects.Add(EpicAssets.DodgeBuffStatusEffect); } [UsedImplicitly] - void OnDestroy() - { + void OnDestroy() { Config.Save(); _instance = null; } - public static bool IsObjectDBReady() - { + public static bool IsObjectDBReady() { // Hack, just making sure the built-in items and prefabs have loaded return ObjectDB.instance != null && ObjectDB.instance.m_items.Count != 0 && ObjectDB.instance.GetItemPrefab("Amber") != null; } - private static void SetupAndvaranaut() - { + private static void SetupAndvaranaut() { var go = EpicAssets.AssetBundle.LoadAsset("Andvaranaut"); ItemDrop prefab = go.GetComponent(); @@ -676,8 +802,7 @@ private static void SetupAndvaranaut() wishbone.m_shared.m_equipStatusEffect = wishboneFinder; // Setup magic item - var magicItem = new MagicItem - { + var magicItem = new MagicItem { Rarity = ItemRarity.Epic, TypeNameOverride = "$mod_epicloot_item_andvaranaut_type" }; @@ -691,8 +816,7 @@ private static void SetupAndvaranaut() PrefabManager.OnPrefabsRegistered -= SetupAndvaranaut; } - private static void SetupStatusEffects() - { + private static void SetupStatusEffects() { var lightning = ObjectDB.instance.GetStatusEffect("Lightning".GetHashCode()); var paralyzed = ScriptableObject.CreateInstance(); Common.Utils.CopyFields(lightning, paralyzed, typeof(StatusEffect)); @@ -703,13 +827,8 @@ private static void SetupStatusEffects() ItemManager.OnItemsRegistered -= SetupStatusEffects; } - public static AssetBundle LoadAssetBundle(string filename) - { - var assembly = Assembly.GetCallingAssembly(); - var assetBundle = AssetBundle.LoadFromStream(assembly.GetManifestResourceStream( - $"{assembly.GetName().Name}.{filename}")); - - return assetBundle; + public static AssetBundle LoadAssetBundle(string filename) { + return AssetBundleLoader.LoadFromResources(filename, typeof(EpicLoot).Assembly); } /// @@ -717,24 +836,18 @@ public static AssetBundle LoadAssetBundle(string filename) /// /// /// - internal static string ReadEmbeddedResourceFile(string filename) - { - using (var stream = typeof(EpicLoot).Assembly.GetManifestResourceStream(filename)) - { - using (var reader = new StreamReader(stream)) - { + internal static string ReadEmbeddedResourceFile(string filename) { + using (var stream = typeof(EpicLoot).Assembly.GetManifestResourceStream(filename)) { + using (var reader = new StreamReader(stream)) { return reader.ReadToEnd(); } } } - internal static List GetEmbeddedResourceNamesFromDirectory(string embedded_location = "EpicLoot.config.") - { + internal static List GetEmbeddedResourceNamesFromDirectory(string embedded_location = "EpicLoot.config.") { List resourcenames = new List(); - foreach (string embeddedResouce in typeof(EpicLoot).Assembly.GetManifestResourceNames()) - { - if (embeddedResouce.Contains(embedded_location)) - { + foreach (string embeddedResouce in typeof(EpicLoot).Assembly.GetManifestResourceNames()) { + if (embeddedResouce.Contains(embedded_location)) { // Got to strip the starting 'EpicLoot.config.' off, so we just take the ending substring resourcenames.Add(embeddedResouce.Substring(16)); } @@ -742,8 +855,7 @@ internal static List GetEmbeddedResourceNamesFromDirectory(string embedd return resourcenames; } - public static bool CanBeMagicItem(ItemDrop.ItemData item) - { + public static bool CanBeMagicItem(ItemDrop.ItemData item) { return item != null && IsPlayerItem(item) && Nonstackable(item) @@ -751,73 +863,62 @@ public static bool CanBeMagicItem(ItemDrop.ItemData item) && IsAllowedMagicItemType(item); } - public static bool IsAllowedMagicItemType(ItemDrop.ItemData item) - { - switch (item.m_shared.m_itemType) - { + public static bool IsAllowedMagicItemType(ItemDrop.ItemData item) { + switch (item.m_shared.m_itemType) { case ItemDrop.ItemData.ItemType.Ammo: case ItemDrop.ItemData.ItemType.AmmoNonEquipable: - return false; + return false; default: return item.IsEquipable(); } } - public static Sprite GetMagicItemBgSprite() - { + public static Sprite GetMagicItemBgSprite() { return HasAuga ? EpicAssets.AugaItemBgSprite : EpicAssets.GenericItemBgSprite; } - public static Sprite GetEquippedSprite() - { + public static Sprite GetEquippedSprite() { return HasAuga ? EpicAssets.AugaEquippedSprite : EpicAssets.EquippedSprite; } - public static Sprite GetSetItemSprite() - { + public static Sprite GetSetItemSprite() { return HasAuga ? EpicAssets.AugaSetItemSprite : EpicAssets.GenericSetItemSprite; } - public static string GetMagicEffectPip(bool hasBeenAugmented) - { - return HasAuga ? (hasBeenAugmented ? "▾" : "♦") : (hasBeenAugmented ? "▼" : "◆"); // //🞠🞛 + // Escapes rather than literal glyphs: this file has no BOM and a CP1252 round-trip once + // corrupted these characters into mojibake. \u25BE = small down triangle, \u2666 = diamond suit + // (Auga); \u25BC = down triangle, \u25C6 = black diamond (default). Alt ideas: U+1F7A0, U+1F79B. + public static string GetMagicEffectPip(bool hasBeenAugmented) { + return HasAuga ? (hasBeenAugmented ? "\u25BE" : "\u2666") : (hasBeenAugmented ? "\u25BC" : "\u25C6"); } - private static bool IsNotRestrictedItem(ItemDrop.ItemData item) - { - if (item.m_dropPrefab != null && LootRoller.Config.RestrictedItems.Contains(item.m_dropPrefab.name)) - { + private static bool IsNotRestrictedItem(ItemDrop.ItemData item) { + if (item.m_dropPrefab != null && LootRoller.Config.RestrictedItems.Contains(item.m_dropPrefab.name)) { return false; } return !LootRoller.Config.RestrictedItems.Contains(item.m_shared.m_name); } - private static bool Nonstackable(ItemDrop.ItemData item) - { + private static bool Nonstackable(ItemDrop.ItemData item) { return item.m_shared.m_maxStackSize == 1; } - private static bool IsPlayerItem(ItemDrop.ItemData item) - { + private static bool IsPlayerItem(ItemDrop.ItemData item) { // WTF, this is the only thing I found different between player usable items and items that are only for enemies return item.m_shared.m_icons.Length > 0; } - public static string GetCharacterCleanName(Character character) - { + public static string GetCharacterCleanName(Character character) { return character.name.Replace("(Clone)", "").Trim(); } - public static string GetSetItemColor() - { + public static string GetSetItemColor() { return ELConfig._setItemColor.Value; } - public static string GetRarityDisplayName(ItemRarity rarity) - { - switch (rarity) - { + public static string GetRarityDisplayName(ItemRarity rarity) { + switch (rarity) { case ItemRarity.Magic: return "$mod_epicloot_Magic"; case ItemRarity.Rare: @@ -833,10 +934,8 @@ public static string GetRarityDisplayName(ItemRarity rarity) } } - public static string GetRarityColor(ItemRarity rarity) - { - switch (rarity) - { + public static string GetRarityColor(ItemRarity rarity) { + switch (rarity) { case ItemRarity.Magic: return GetColor(ELConfig._magicRarityColor.Value); case ItemRarity.Rare: @@ -852,21 +951,15 @@ public static string GetRarityColor(ItemRarity rarity) } } - public static Color GetRarityColorARGB(ItemRarity rarity) - { + public static Color GetRarityColorARGB(ItemRarity rarity) { return ColorUtility.TryParseHtmlString(GetRarityColor(rarity), out var color) ? color : Color.white; } - private static string GetColor(string configValue) - { - if (configValue.StartsWith("#")) - { + private static string GetColor(string configValue) { + if (configValue.StartsWith("#")) { return configValue; - } - else - { - if (MagicItemColors.TryGetValue(configValue, out var color)) - { + } else { + if (MagicItemColors.TryGetValue(configValue, out var color)) { return color; } } @@ -874,10 +967,8 @@ private static string GetColor(string configValue) return "#000000"; } - public static int GetRarityIconIndex(ItemRarity rarity) - { - switch (rarity) - { + public static int GetRarityIconIndex(ItemRarity rarity) { + switch (rarity) { case ItemRarity.Magic: return Mathf.Clamp(ELConfig._magicMaterialIconColor.Value, 0, 9); case ItemRarity.Rare: @@ -893,69 +984,56 @@ public static int GetRarityIconIndex(ItemRarity rarity) } } - public static AudioClip GetMagicItemDropSFX(ItemRarity rarity) - { - return EpicAssets.MagicItemDropSFX[(int) rarity]; + public static AudioClip GetMagicItemDropSFX(ItemRarity rarity) { + return EpicAssets.MagicItemDropSFX[(int)rarity]; } - public static GatedItemTypeMode GetGatedItemTypeMode() - { + public static GatedItemTypeMode GetGatedItemTypeMode() { return ELConfig._gatedItemTypeModeConfig.Value; } - public static BossDropMode GetBossTrophyDropMode() - { + public static BossDropMode GetBossTrophyDropMode() { return ELConfig._bossTrophyDropMode.Value; } - public static float GetBossTrophyDropPlayerRange() - { + public static float GetBossTrophyDropPlayerRange() { return ELConfig._bossTrophyDropPlayerRange.Value; } - public static float GetBossCryptKeyPlayerRange() - { + public static float GetBossCryptKeyPlayerRange() { return ELConfig._bossCryptKeyDropPlayerRange.Value; } - public static BossDropMode GetBossCryptKeyDropMode() - { + public static BossDropMode GetBossCryptKeyDropMode() { return ELConfig._bossCryptKeyDropMode.Value; } - public static BossDropMode GetBossWishboneDropMode() - { + public static BossDropMode GetBossWishboneDropMode() { return ELConfig._bossWishboneDropMode.Value; } - public static float GetBossWishboneDropPlayerRange() - { + public static float GetBossWishboneDropPlayerRange() { return ELConfig._bossWishboneDropPlayerRange.Value; } - public static int GetAndvaranautRange() - { - return ELConfig._andvaranautRange.Value; + public static int GetAndvaranautRange() { + return ELConfig._andvaranautRange.Value; } - public static bool IsAdventureModeEnabled() - { + public static bool IsAdventureModeEnabled() { return ELConfig._adventureModeEnabled.Value; } - public static float GetWorldLuckFactor() - { + public static float GetWorldLuckFactor() { return _instance._worldLuckFactor; } // TODO, why isn't this used? - public static void SetWorldLuckFactor(float luckFactor) - { + public static void SetWorldLuckFactor(float luckFactor) { _instance._worldLuckFactor = luckFactor; } - private void SetupWatcher() - { + private void SetupWatcher() { FileSystemWatcher watcher = new(BepInEx.Paths.ConfigPath, ConfigFileName); watcher.Changed += ReadConfigValues; watcher.Created += ReadConfigValues; @@ -968,19 +1046,15 @@ private void SetupWatcher() private DateTime _lastReloadTime; private const long RELOAD_DELAY = 10000000; // One second - private void ReadConfigValues(object sender, FileSystemEventArgs e) - { + private void ReadConfigValues(object sender, FileSystemEventArgs e) { var now = DateTime.Now; var time = now.Ticks - _lastReloadTime.Ticks; if (!File.Exists(ConfigFileFullPath) || time < RELOAD_DELAY) return; - try - { + try { Log("Attempting to reload configuration..."); Config.Reload(); - } - catch - { + } catch { Log($"There was an issue loading {ConfigFileName}"); return; } diff --git a/EpicLoot/EpicLoot.csproj b/EpicLoot/EpicLoot.csproj index d8d89d18f..5dd1e27ef 100755 --- a/EpicLoot/EpicLoot.csproj +++ b/EpicLoot/EpicLoot.csproj @@ -1,6 +1,7 @@  + Library net481 @@ -30,6 +31,9 @@ + + + @@ -53,19 +57,6 @@ - - - EpicLoot-UnityLib.dll - - - - - - {8DABA031-AEA4-472F-A9AA-DE8B1AA69DE5} - EpicLoot-UnityLib - - - @@ -73,5 +64,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/EpicLoot/EpicLoot.sln b/EpicLoot/EpicLoot.sln index 5585759f9..47a0a7539 100755 --- a/EpicLoot/EpicLoot.sln +++ b/EpicLoot/EpicLoot.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 +# Visual Studio Version 18 +VisualStudioVersion = 18.7.12002.237 oobstable MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EpicLoot", "EpicLoot.csproj", "{E09E115B-AE11-480A-B4F4-56DE2FC2D9C8}" EndProject @@ -22,4 +22,7 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8F61D22A-7D04-451F-8DD2-064B70513685} EndGlobalSection + GlobalSection(SharedMSBuildProjectFiles) = preSolution + ..\Common\Common.projitems*{e09e115b-ae11-480a-b4f4-56de2fc2d9c8}*SharedItemsImports = 5 + EndGlobalSection EndGlobal diff --git a/EpicLoot/Properties/AssemblyInfo.cs b/EpicLoot/Properties/AssemblyInfo.cs index 97063c351..9b0fdc9c1 100644 --- a/EpicLoot/Properties/AssemblyInfo.cs +++ b/EpicLoot/Properties/AssemblyInfo.cs @@ -31,5 +31,9 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.12.15")] -[assembly: AssemblyFileVersion("0.12.15")] +// +// KEEP IN SYNC with EpicLoot.Version (EpicLoot.cs) and thunderstore/manifest.json. The csproj sets +// GenerateAssemblyInfo=false, so these are hand-maintained; anything that version-binds against the +// assembly rather than the BepInEx plugin metadata reads these. +[assembly: AssemblyVersion("0.13.0")] +[assembly: AssemblyFileVersion("0.13.0")] diff --git a/EpicLoot/README.md b/EpicLoot/README.md deleted file mode 100755 index ffe995458..000000000 --- a/EpicLoot/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Epic Loot - -Author: [RandyKnapp](https://discord.gg/ZNhYeavv3C) -Source: [Github](https://github.com/OrianaVenture/Randy_Vapok_ValheimMods/tree/main/EpicLoot) -Patreon: [patreon.com/randyknapp](https://www.patreon.com/randyknapp) -Discord: [RandyKnapp's Mod Community](https://discord.gg/ZNhYeavv3C) -Patch notes: [Github Patchnotes](https://github.com/OrianaVenture/Randy_Vapok_ValheimMods/blob/main/EpicLoot/CHANGELOG.md) - -This mod aims to add a loot drop experience to Valheim similar to Diablo or other RPGs. Monsters and chests can now drop Magic, Rare, Epic, Legendary, or Mythic magic items. Each magic item has a number of magic effects on it, that give bonuses to the item or your character when that magic item is equipped. - -The mod is currently in ***Early Access***! That means it's **not done**! Be patient as the author adds new features, fixes bugs, and finishes things up. If you want to help, please provide feedback on the [Nexus mod page](https://www.nexusmods.com/valheim/mods/387) or on the [github](https://github.com/RandyKnapp/ValheimMods/tree/main/EpicLoot) for the following: - - * **Bugs** *(check to make sure your bug is new and not already reported)* - * **Balance Issues** *(drops too strong? Too weak? Ruin the crafting progression?)* - * **Missing content** *(check the TODO list below to make sure the author isn't already planning to do it)* - * **Suggestions** for new magic item effects - * **Suggestions** for something else like UI or art improvements - -***EpicLoot works in multiplayer and on dedicated servers!*** The server and all players should have the mod and its dependencies installed. - -## Version 0.12! - -Introducing a large new feature: **Runes!** - -* New unidentified items can drop which can be brought to the enchantment table to reveal their effects. -* Sacrifice an existing magic item to extract a magic effect into a Runestone. -* Use a Rune on an existing magic item to augment an effect. - -You may need to enable this feature in your existing configuration file on a version upgrade. Check the randyknapp.mods.epicloot.cfg file "Enchanting Table - Table Features Active" has "Rune" in the list. - -See the wiki on thunderstore for more information! Link below! - -## Ashlands Update Notice! - -The Ashlands update introduced the addition of the Mythic tier rarity, as well as a huge rebalance of the mod to be more vanilla friendly. If you are upgrading from a previous version you may wish to restore the old values. There is now an add-on with patches to restore the old Epic values: [thunderstore link](https://thunderstore.io/c/valheim/p/RandyKnapp/EpicPatches_EpicLoot/). - -Mythic sets have not been created for the base mod, but the feature is available if you wish to create your own sets and add them with the patching system. - -## Documentation - -For more information please see the [wiki on thunderstore](https://thunderstore.io/c/valheim/p/RandyKnapp/EpicLoot/wiki/). - -## Credits - -Epic Loot Team Members: - * [Vapok](https://github.com/Vapok) - Joined in Dec 2022, made hundreds of changes and bugfixes since. - * [OrianaVenture](https://github.com/OrianaVenture) - Joined in Dec 2023, helping with maintenance and improvements. - * [Warp](https://github.com/jneb802) - Joined in Oct 2024, helped design new magic effects. - * [MidnightFX](https://github.com/MidnightsFX) - Joined in Oct 2024, helping with maintenance and improvements. - * [Rusty](https://github.com/RustyMods) - Joined in Oct 2025, helped create the API. - -Contibutions from the following modders were invaluable and appreciated: - * [blaxxun (CLLC)](https://www.nexusmods.com/valheim/mods/495) - bugfixes, config sync, various magic item effects - * [M3TO](https://github.com/M3TO) - bugfixes - * [jsza](https://github.com/jsza) - bugfixes - * [maxrd2](https://github.com/maxrd2) - bugfixes - * [nanonull](https://github.com/nanonull) - bugfixes, lifesteal - * [xPucTu4](https://github.com/xPucTu4) - bugfixes - * LitanyOfFire - legendary definitions - * [Digitalroot](https://github.com/Digitalroot) - Help with testing - -## Installation - -Copy the contents of "plugins" to a new folder called "EpicLoot" in your BepInEx/plugins directory (on both clients and dedicated servers). When using a thunderstore mod manager these files should be placed in the correct directory for you. - -## Cheats - -Moved to a new page on the [Wiki](https://thunderstore.io/c/valheim/p/RandyKnapp/EpicLoot/wiki/2750-7cheatscommands/)! - -## Current Known Mod Conflicts - - * **BetterUI**: You won't be able to see the magic item properties in the tooltip. Go to the BetterUI config and set `showCustomTooltips = false`. - -## Known Bugs - - * Gamepad: Still some gamepad issues, especially when using other mods that change the inventory. \ No newline at end of file diff --git a/EpicLoot/README.md b/EpicLoot/README.md new file mode 120000 index 000000000..568b102e2 --- /dev/null +++ b/EpicLoot/README.md @@ -0,0 +1 @@ +thunderstore/README.md \ No newline at end of file diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/0Harmony.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/0Harmony.dll new file mode 100644 index 000000000..99daef723 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/0Harmony.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/BepInEx.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/BepInEx.dll new file mode 100644 index 000000000..010fb08f8 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/BepInEx.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/EpicLoot-UnityLib.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/EpicLoot-UnityLib.dll new file mode 100644 index 000000000..1cabc12d4 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/EpicLoot-UnityLib.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/Jotunn.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/Jotunn.dll new file mode 100644 index 000000000..a5f01d0fd Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/Jotunn.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/Mono.Cecil.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/Mono.Cecil.dll new file mode 100644 index 000000000..18735f93b Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/Mono.Cecil.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/MonoMod.RuntimeDetour.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/MonoMod.RuntimeDetour.dll new file mode 100644 index 000000000..5709bbbd2 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/MonoMod.RuntimeDetour.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/MonoMod.Utils.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/MonoMod.Utils.dll new file mode 100644 index 000000000..1d9fb3571 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/MonoMod.Utils.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Assembly-CSharp.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Assembly-CSharp.dll new file mode 100644 index 000000000..79b90849b Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Assembly-CSharp.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/PlayFab.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/PlayFab.dll new file mode 100644 index 000000000..e791b3c78 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/PlayFab.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/PlayFabParty.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/PlayFabParty.dll new file mode 100644 index 000000000..3097a968d Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/PlayFabParty.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/SoftReferenceableAssets.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/SoftReferenceableAssets.dll new file mode 100644 index 000000000..22f79c5dd Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/SoftReferenceableAssets.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Splatform.Steam.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Splatform.Steam.dll new file mode 100644 index 000000000..5996fab78 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Splatform.Steam.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Splatform.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Splatform.dll new file mode 100644 index 000000000..52ac52a62 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/Splatform.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_googleanalytics.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_googleanalytics.dll new file mode 100644 index 000000000..7f82d8f10 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_googleanalytics.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_guiutils.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_guiutils.dll new file mode 100644 index 000000000..d26471632 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_guiutils.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_lux.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_lux.dll new file mode 100644 index 000000000..044e02ed4 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_lux.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_postprocessing.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_postprocessing.dll new file mode 100644 index 000000000..6052fccd4 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_postprocessing.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_simplemeshcombine.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_simplemeshcombine.dll new file mode 100644 index 000000000..fd740b52f Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_simplemeshcombine.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_sunshafts.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_sunshafts.dll new file mode 100644 index 000000000..a60a17c4a Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_sunshafts.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_utils.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_utils.dll new file mode 100644 index 000000000..8b110b168 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_utils.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_valheim.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_valheim.dll new file mode 100644 index 000000000..24592c7a7 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/assembly_valheim.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/com.rlabrecque.steamworks.net.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/com.rlabrecque.steamworks.net.dll new file mode 100644 index 000000000..cd1ac373e Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/com.rlabrecque.steamworks.net.dll differ diff --git a/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/gui_framework.dll b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/gui_framework.dll new file mode 100644 index 000000000..30679da56 Binary files /dev/null and b/EpicLoot/ValheimUnity/Assets/ExternalLibraries/valheim/gui_framework.dll differ diff --git a/EpicLoot/config-patches/EL.examples.shardstoneconversions.json b/EpicLoot/config-patches/EL.examples.shardstoneconversions.json new file mode 100644 index 000000000..5670c8a35 --- /dev/null +++ b/EpicLoot/config-patches/EL.examples.shardstoneconversions.json @@ -0,0 +1,40 @@ +{ + "TargetFile": "shardstoneconversions.json", + "Author": "EpicLoot", + "Priority": 2000, + "RequireAll": true, + "Patches": [ + { + "Path": "$.MaterialConversions[?(@.Name == 'ShardStoneUpgrade_Red_Magic_to_Rare')].Resources", + "Action": "Overwrite", + "Value": [ + { "Item": "Red_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 3 }, + { "Item": "Coins", "Amount": 10 } + ] + }, + { + "Path": "$.MaterialConversions[?(@.Name == 'ShardStoneUpgrade_Yagluth_Legendary_to_Mythic')].Resources", + "Action": "Append", + "Value": { "Item": "YagluthDrop", "Amount": 2 } + }, + { + "Path": "$.MaterialConversions[?(@.Name == 'ShardStoneConvert_Red_Mythic_to_Essence')].Amount", + "Action": "Overwrite", + "Value": 4 + }, + { + "Path": "$.MaterialConversions", + "Action": "Append", + "Value": { + "Name": "ShardStoneConvert_Fader_Mythic_to_Shard", + "Product": "ShardMythic", + "Amount": 3, + "Type": "Convert", + "Resources": [ + { "Item": "Fader_Mythic_ShardStone", "Amount": 1 } + ] + } + } + ] +} diff --git a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.abilities.json b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.abilities.json index f9bbabef1..1d937a30e 100644 --- a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.abilities.json +++ b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.abilities.json @@ -1,4 +1,4 @@ -{ +{ "TargetFile": "abilities.json", "Author": "EpicLoot", "Priority": 2000, diff --git a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.enchantingupgrades.json b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.enchantingupgrades.json index 31240159d..228bae604 100644 --- a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.enchantingupgrades.json +++ b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.enchantingupgrades.json @@ -1,4 +1,4 @@ -{ +{ "TargetFile": "enchantingupgrades.json", "Author": "EpicLoot", "Priority": 2000, diff --git a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.materialconversions.json b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.materialconversions.json index 4eea56a42..623943017 100644 --- a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.materialconversions.json +++ b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.materialconversions.json @@ -1,4 +1,4 @@ -{ +{ "TargetFile": "materialconversions.json", "Author": "EpicLoot", "Priority": 2000, diff --git a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.recipes.json b/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.recipes.json deleted file mode 100644 index 5a381cc92..000000000 --- a/EpicLoot/config-patches/OLD-mega-overwrite/EL.mega.recipes.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "TargetFile": "recipes.json", - "Author": "EpicLoot", - "Priority": 2000, - "RequireAll": true, - "Patches": [ - { - "Path": "$.recipes", - "Action": "Overwrite", - "Value": [ - { - "name": "Recipe_LeatherBelt", - "item": "LeatherBelt", - "craftingStation": "forge", - "resources": [ - { "item": "LeatherScraps", "amount": 4 }, - { "item": "Bronze", "amount": 1 } - ] - }, - { - "name": "Recipe_SilverRing", - "item": "SilverRing", - "craftingStation": "forge", - "resources": [ - { "item": "Silver", "amount": 1 } - ] - }, - { - "name": "Recipe_GoldRubyRing", - "item": "GoldRubyRing", - "craftingStation": "forge", - "resources": [ - { "item": "Coins", "amount": 200 }, - { "item": "Ruby", "amount": 1 } - ] - } - ] - } - ] -} \ No newline at end of file diff --git a/EpicLoot/config/abilities.json b/EpicLoot/config/abilities.json index 7805c2bed..7cebe2993 100755 --- a/EpicLoot/config/abilities.json +++ b/EpicLoot/config/abilities.json @@ -1,4 +1,4 @@ -{ +{ "Abilities": [ { "ID" : "Bulwark", diff --git a/EpicLoot/config/adventuredata.json b/EpicLoot/config/adventuredata.json index 429460220..a22a62cf0 100755 --- a/EpicLoot/config/adventuredata.json +++ b/EpicLoot/config/adventuredata.json @@ -271,6 +271,7 @@ "IncreaseRadiusCount": 3, "RadiusInterval": 50, "MinimapAreaRadius": 45, + "MaxSpawnSearchExpansions": 5, "SaleItems": [ { "Item": "RunestoneMagic", "ForestTokenCost": 5 }, { "Item": "RunestoneRare", "ForestTokenCost": 10 }, diff --git a/EpicLoot/config/enchantcosts.json b/EpicLoot/config/enchantcosts.json index c5e84d091..c233acd73 100755 --- a/EpicLoot/config/enchantcosts.json +++ b/EpicLoot/config/enchantcosts.json @@ -32,6 +32,46 @@ ] }, "DisenchantProducts": [ + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "Rarity": "Magic", + "Products": [ + { "Item": "ShardMagic", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "Rarity": "Rare", + "Products": [ + { "Item": "ShardRare", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "Rarity": "Epic", + "Products": [ + { "Item": "ShardEpic", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "Rarity": "Legendary", + "Products": [ + { "Item": "ShardLegendary", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "Rarity": "Mythic", + "Products": [ + { "Item": "ShardMythic", "Amount": 2 } + ] + }, { "IsMagic": true, "ItemTypes": [ diff --git a/EpicLoot/config/enchantingupgrades.json b/EpicLoot/config/enchantingupgrades.json index 79cae9f23..8beb353bd 100755 --- a/EpicLoot/config/enchantingupgrades.json +++ b/EpicLoot/config/enchantingupgrades.json @@ -1,4 +1,4 @@ -{ +{ "DefaultFeatureLevels": { "Sacrifice": 0, "ConvertMaterials": -1, diff --git a/EpicLoot/config/loottables.json b/EpicLoot/config/loottables.json index 93a55046b..be7ea5d44 100755 --- a/EpicLoot/config/loottables.json +++ b/EpicLoot/config/loottables.json @@ -6,6 +6,13 @@ "Legendary": [ [4, 80], [5, 18], [6, 2] ], "Mythic": [ [5, 80], [6, 18], [7, 2] ] }, + "SocketCounts": { + "Magic": [ [0, 90], [1, 10] ], + "Rare": [ [0, 75], [1, 25] ], + "Epic": [ [0, 50], [1, 45], [2, 5] ], + "Legendary": [ [0, 15], [1, 55], [2, 30] ], + "Mythic": [ [0, 5], [1, 30], [2, 40], [3, 25] ] + }, "RestrictedItems": [ "$item_tankard", "$item_tankard_odin", @@ -109,6 +116,167 @@ { "Item": "GoldRubyRing" } ] }, + { + "Name": "ShardT1", + "Loot": [ + { "Item": "Yellow_Magic_ShardStone", "Rarity": [ 100, 0, 0, 0, 0 ], + "RarityItems": { "Magic": "Yellow_Magic_ShardStone", "Rare": "Yellow_Rare_ShardStone", "Epic": "Yellow_Epic_ShardStone", "Legendary": "Yellow_Legendary_ShardStone", "Mythic": "Yellow_Mythic_ShardStone" } }, + { "Item": "Cyan_Magic_ShardStone", "Rarity": [ 100, 0, 0, 0, 0 ], + "RarityItems": { "Magic": "Cyan_Magic_ShardStone", "Rare": "Cyan_Rare_ShardStone", "Epic": "Cyan_Epic_ShardStone", "Legendary": "Cyan_Legendary_ShardStone", "Mythic": "Cyan_Mythic_ShardStone" } }, + { "Item": "Green_Magic_ShardStone", "Rarity": [ 100, 0, 0, 0, 0 ], + "RarityItems": { "Magic": "Green_Magic_ShardStone", "Rare": "Green_Rare_ShardStone", "Epic": "Green_Epic_ShardStone", "Legendary": "Green_Legendary_ShardStone", "Mythic": "Green_Mythic_ShardStone" } } + ] + }, + { + "Name": "ShardT2", + "Loot": [ + { "Item": "Red_Rare_ShardStone", "Weight": 4, "Rarity": [ 0, 100, 0, 0, 0 ], + "RarityItems": { "Magic": "Red_Magic_ShardStone", "Rare": "Red_Rare_ShardStone", "Epic": "Red_Epic_ShardStone", "Legendary": "Red_Legendary_ShardStone", "Mythic": "Red_Mythic_ShardStone" } }, + { "Item": "Grey_Rare_ShardStone", "Weight": 4, "Rarity": [ 0, 100, 0, 0, 0 ], + "RarityItems": { "Magic": "Grey_Magic_ShardStone", "Rare": "Grey_Rare_ShardStone", "Epic": "Grey_Epic_ShardStone", "Legendary": "Grey_Legendary_ShardStone", "Mythic": "Grey_Mythic_ShardStone" } }, + { "Item": "Golden_Rare_ShardStone", "Weight": 4, "Rarity": [ 0, 100, 0, 0, 0 ], + "RarityItems": { "Magic": "Golden_Magic_ShardStone", "Rare": "Golden_Rare_ShardStone", "Epic": "Golden_Epic_ShardStone", "Legendary": "Golden_Legendary_ShardStone", "Mythic": "Golden_Mythic_ShardStone" } }, + { "Item": "ShardT1", "Weight": 3 } + ] + }, + { + "Name": "ShardT3", + "Loot": [ + { "Item": "White_Epic_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 100, 0, 0 ], + "RarityItems": { "Magic": "White_Magic_ShardStone", "Rare": "White_Rare_ShardStone", "Epic": "White_Epic_ShardStone", "Legendary": "White_Legendary_ShardStone", "Mythic": "White_Mythic_ShardStone" } }, + { "Item": "LightBlue_Epic_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 100, 0, 0 ], + "RarityItems": { "Magic": "LightBlue_Magic_ShardStone", "Rare": "LightBlue_Rare_ShardStone", "Epic": "LightBlue_Epic_ShardStone", "Legendary": "LightBlue_Legendary_ShardStone", "Mythic": "LightBlue_Mythic_ShardStone" } }, + { "Item": "DarkBlue_Epic_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 100, 0, 0 ], + "RarityItems": { "Magic": "DarkBlue_Magic_ShardStone", "Rare": "DarkBlue_Rare_ShardStone", "Epic": "DarkBlue_Epic_ShardStone", "Legendary": "DarkBlue_Legendary_ShardStone", "Mythic": "DarkBlue_Mythic_ShardStone" } }, + { "Item": "ShardT2", "Weight": 3 } + ] + }, + { + "Name": "ShardT4", + "Loot": [ + { "Item": "Black_Legendary_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 100, 0 ], + "RarityItems": { "Magic": "Black_Magic_ShardStone", "Rare": "Black_Rare_ShardStone", "Epic": "Black_Epic_ShardStone", "Legendary": "Black_Legendary_ShardStone", "Mythic": "Black_Mythic_ShardStone" } }, + { "Item": "Peach_Legendary_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 100, 0 ], + "RarityItems": { "Magic": "Peach_Magic_ShardStone", "Rare": "Peach_Rare_ShardStone", "Epic": "Peach_Epic_ShardStone", "Legendary": "Peach_Legendary_ShardStone", "Mythic": "Peach_Mythic_ShardStone" } }, + { "Item": "DarkGreen_Legendary_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 100, 0 ], + "RarityItems": { "Magic": "DarkGreen_Magic_ShardStone", "Rare": "DarkGreen_Rare_ShardStone", "Epic": "DarkGreen_Epic_ShardStone", "Legendary": "DarkGreen_Legendary_ShardStone", "Mythic": "DarkGreen_Mythic_ShardStone" } }, + { "Item": "ShardT3", "Weight": 3 } + ] + }, + { + "Name": "ShardT5", + "Loot": [ + { "Item": "LightGreen_Legendary_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 100, 0 ], + "RarityItems": { "Magic": "LightGreen_Magic_ShardStone", "Rare": "LightGreen_Rare_ShardStone", "Epic": "LightGreen_Epic_ShardStone", "Legendary": "LightGreen_Legendary_ShardStone", "Mythic": "LightGreen_Mythic_ShardStone" } }, + { "Item": "Purple_Legendary_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 100, 0 ], + "RarityItems": { "Magic": "Purple_Magic_ShardStone", "Rare": "Purple_Rare_ShardStone", "Epic": "Purple_Epic_ShardStone", "Legendary": "Purple_Legendary_ShardStone", "Mythic": "Purple_Mythic_ShardStone" } }, + { "Item": "Pink_Legendary_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 100, 0 ], + "RarityItems": { "Magic": "Pink_Magic_ShardStone", "Rare": "Pink_Rare_ShardStone", "Epic": "Pink_Epic_ShardStone", "Legendary": "Pink_Legendary_ShardStone", "Mythic": "Pink_Mythic_ShardStone" } }, + { "Item": "ShardT4", "Weight": 3 } + ] + }, + { + "Name": "ShardT6", + "Loot": [ + { "Item": "DarkPurple_Mythic_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "DarkPurple_Magic_ShardStone", "Rare": "DarkPurple_Rare_ShardStone", "Epic": "DarkPurple_Epic_ShardStone", "Legendary": "DarkPurple_Legendary_ShardStone", "Mythic": "DarkPurple_Mythic_ShardStone" } }, + { "Item": "Orange_Mythic_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Orange_Magic_ShardStone", "Rare": "Orange_Rare_ShardStone", "Epic": "Orange_Epic_ShardStone", "Legendary": "Orange_Legendary_ShardStone", "Mythic": "Orange_Mythic_ShardStone" } }, + { "Item": "DarkRed_Mythic_ShardStone", "Weight": 4, "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "DarkRed_Magic_ShardStone", "Rare": "DarkRed_Rare_ShardStone", "Epic": "DarkRed_Epic_ShardStone", "Legendary": "DarkRed_Legendary_ShardStone", "Mythic": "DarkRed_Mythic_ShardStone" } }, + { "Item": "ShardT5", "Weight": 3 } + ] + }, + { + "Name": "ShardT7", + "Loot": [ + { "Item": "Red_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Red_Magic_ShardStone", "Rare": "Red_Rare_ShardStone", "Epic": "Red_Epic_ShardStone", "Legendary": "Red_Legendary_ShardStone", "Mythic": "Red_Mythic_ShardStone" } }, + { "Item": "Yellow_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Yellow_Magic_ShardStone", "Rare": "Yellow_Rare_ShardStone", "Epic": "Yellow_Epic_ShardStone", "Legendary": "Yellow_Legendary_ShardStone", "Mythic": "Yellow_Mythic_ShardStone" } }, + { "Item": "Cyan_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Cyan_Magic_ShardStone", "Rare": "Cyan_Rare_ShardStone", "Epic": "Cyan_Epic_ShardStone", "Legendary": "Cyan_Legendary_ShardStone", "Mythic": "Cyan_Mythic_ShardStone" } }, + { "Item": "Orange_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Orange_Magic_ShardStone", "Rare": "Orange_Rare_ShardStone", "Epic": "Orange_Epic_ShardStone", "Legendary": "Orange_Legendary_ShardStone", "Mythic": "Orange_Mythic_ShardStone" } }, + { "Item": "Pink_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Pink_Magic_ShardStone", "Rare": "Pink_Rare_ShardStone", "Epic": "Pink_Epic_ShardStone", "Legendary": "Pink_Legendary_ShardStone", "Mythic": "Pink_Mythic_ShardStone" } }, + { "Item": "Black_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Black_Magic_ShardStone", "Rare": "Black_Rare_ShardStone", "Epic": "Black_Epic_ShardStone", "Legendary": "Black_Legendary_ShardStone", "Mythic": "Black_Mythic_ShardStone" } }, + { "Item": "White_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "White_Magic_ShardStone", "Rare": "White_Rare_ShardStone", "Epic": "White_Epic_ShardStone", "Legendary": "White_Legendary_ShardStone", "Mythic": "White_Mythic_ShardStone" } }, + { "Item": "Green_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Green_Magic_ShardStone", "Rare": "Green_Rare_ShardStone", "Epic": "Green_Epic_ShardStone", "Legendary": "Green_Legendary_ShardStone", "Mythic": "Green_Mythic_ShardStone" } }, + { "Item": "Purple_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Purple_Magic_ShardStone", "Rare": "Purple_Rare_ShardStone", "Epic": "Purple_Epic_ShardStone", "Legendary": "Purple_Legendary_ShardStone", "Mythic": "Purple_Mythic_ShardStone" } }, + { "Item": "Grey_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Grey_Magic_ShardStone", "Rare": "Grey_Rare_ShardStone", "Epic": "Grey_Epic_ShardStone", "Legendary": "Grey_Legendary_ShardStone", "Mythic": "Grey_Mythic_ShardStone" } }, + { "Item": "DarkRed_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "DarkRed_Magic_ShardStone", "Rare": "DarkRed_Rare_ShardStone", "Epic": "DarkRed_Epic_ShardStone", "Legendary": "DarkRed_Legendary_ShardStone", "Mythic": "DarkRed_Mythic_ShardStone" } }, + { "Item": "DarkGreen_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "DarkGreen_Magic_ShardStone", "Rare": "DarkGreen_Rare_ShardStone", "Epic": "DarkGreen_Epic_ShardStone", "Legendary": "DarkGreen_Legendary_ShardStone", "Mythic": "DarkGreen_Mythic_ShardStone" } }, + { "Item": "DarkBlue_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "DarkBlue_Magic_ShardStone", "Rare": "DarkBlue_Rare_ShardStone", "Epic": "DarkBlue_Epic_ShardStone", "Legendary": "DarkBlue_Legendary_ShardStone", "Mythic": "DarkBlue_Mythic_ShardStone" } }, + { "Item": "DarkPurple_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "DarkPurple_Magic_ShardStone", "Rare": "DarkPurple_Rare_ShardStone", "Epic": "DarkPurple_Epic_ShardStone", "Legendary": "DarkPurple_Legendary_ShardStone", "Mythic": "DarkPurple_Mythic_ShardStone" } }, + { "Item": "Golden_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Golden_Magic_ShardStone", "Rare": "Golden_Rare_ShardStone", "Epic": "Golden_Epic_ShardStone", "Legendary": "Golden_Legendary_ShardStone", "Mythic": "Golden_Mythic_ShardStone" } }, + { "Item": "LightBlue_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "LightBlue_Magic_ShardStone", "Rare": "LightBlue_Rare_ShardStone", "Epic": "LightBlue_Epic_ShardStone", "Legendary": "LightBlue_Legendary_ShardStone", "Mythic": "LightBlue_Mythic_ShardStone" } }, + { "Item": "LightGreen_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "LightGreen_Magic_ShardStone", "Rare": "LightGreen_Rare_ShardStone", "Epic": "LightGreen_Epic_ShardStone", "Legendary": "LightGreen_Legendary_ShardStone", "Mythic": "LightGreen_Mythic_ShardStone" } }, + { "Item": "Peach_Mythic_ShardStone", "Rarity": [ 0, 0, 0, 0, 100 ], + "RarityItems": { "Magic": "Peach_Magic_ShardStone", "Rare": "Peach_Rare_ShardStone", "Epic": "Peach_Epic_ShardStone", "Legendary": "Peach_Legendary_ShardStone", "Mythic": "Peach_Mythic_ShardStone" } } + ] + }, + { + "Name": "ShardUnique", + "Loot": [ + { "Item": "Firewalker_Epic_ShardStone", "Rarity": [ 0, 0, 55, 35, 10 ], + "RarityItems": { "Epic": "Firewalker_Epic_ShardStone", "Legendary": "Firewalker_Legendary_ShardStone", "Mythic": "Firewalker_Mythic_ShardStone" } }, + { "Item": "Stormcaller_Epic_ShardStone", "Rarity": [ 0, 0, 55, 35, 10 ], + "RarityItems": { "Epic": "Stormcaller_Epic_ShardStone", "Legendary": "Stormcaller_Legendary_ShardStone", "Mythic": "Stormcaller_Mythic_ShardStone" } } + ] + }, + { + "Name": "ShardStone_None", + "Loot": [ { "Item": "ShardT1" } ] + }, + { + "Name": "ShardStone_Meadows", + "Loot": [ { "Item": "ShardT1" } ] + }, + { + "Name": "ShardStone_BlackForest", + "Loot": [ { "Item": "ShardT1" } ] + }, + { + "Name": "ShardStone_Ocean", + "Loot": [ { "Item": "ShardT1" }, { "Item": "ShardT2" } ] + }, + { + "Name": "ShardStone_Swamp", + "Loot": [ { "Item": "ShardT2" } ] + }, + { + "Name": "ShardStone_Mountain", + "Loot": [ { "Item": "ShardT3" } ] + }, + { + "Name": "ShardStone_Plains", + "Loot": [ { "Item": "ShardT4" } ] + }, + { + "Name": "ShardStone_Mistlands", + "Loot": [ { "Item": "ShardT5" } ] + }, + { + "Name": "ShardStone_AshLands", + "Loot": [ { "Item": "ShardT6" } ] + }, + { + "Name": "ShardStone_DeepNorth", + "Loot": [ { "Item": "ShardT7" } ] + }, { "Name": "Tier0Weapons", "Loot": [ @@ -978,6 +1146,366 @@ } ] }, + { + "Object": "Tier3EliteMob", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 85], [1, 15] ], + "Loot": [ + { "Item": "Tier3Mob.1", "Weight": 20 }, + { "Item": "ShardT1", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 2, + "Drops": [ [0, 75], [1, 25] ], + "Loot": [ + { "Item": "Tier3Mob.2", "Weight": 20 }, + { "Item": "ShardT1", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 3, + "Drops": [ [0, 65], [1, 34], [2, 1] ], + "Loot": [ + { "Item": "Tier3Mob.3", "Weight": 20 }, + { "Item": "ShardT1", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 4, + "Drops": [ [0, 50], [1, 48], [2, 2] ], + "Loot": [ + { "Item": "Tier3Mob.4", "Weight": 20 }, + { "Item": "ShardT1", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 5, + "Drops": [ [0, 35], [1, 61], [2, 3], [3, 1] ], + "Loot": [ + { "Item": "Tier3Mob.5", "Weight": 20 }, + { "Item": "ShardT1", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 6, + "Drops": [ [0, 20], [1, 74], [2, 4], [3, 2] ], + "Loot": [ + { "Item": "Tier3Mob.6", "Weight": 20 }, + { "Item": "ShardT1", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + } + ] + }, + { + "Object": "Tier4EliteMob", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 80], [1, 20] ], + "Loot": [ + { "Item": "Tier4Mob.1", "Weight": 20 }, + { "Item": "ShardT2", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 2, + "Drops": [ [0, 65], [1, 34], [2, 1] ], + "Loot": [ + { "Item": "Tier4Mob.2", "Weight": 20 }, + { "Item": "ShardT2", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 3, + "Drops": [ [0, 50], [1, 48], [2, 2] ], + "Loot": [ + { "Item": "Tier4Mob.3", "Weight": 20 }, + { "Item": "ShardT2", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 4, + "Drops": [ [0, 30], [1, 66], [2, 3], [3, 1] ], + "Loot": [ + { "Item": "Tier4Mob.4", "Weight": 20 }, + { "Item": "ShardT2", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 5, + "Drops": [ [0, 10], [1, 84], [2, 4], [3, 2] ], + "Loot": [ + { "Item": "Tier4Mob.5", "Weight": 20 }, + { "Item": "ShardT2", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 6, + "Drops": [ [0, 0], [1, 92], [2, 5], [3, 3] ], + "Loot": [ + { "Item": "Tier4Mob.6", "Weight": 20 }, + { "Item": "ShardT2", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + } + ] + }, + { + "Object": "Tier5EliteMob", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 75], [1, 24], [2, 1] ], + "Loot": [ + { "Item": "Tier5Mob.1", "Weight": 20 }, + { "Item": "ShardT3", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 2, + "Drops": [ [0, 60], [1, 38], [2, 2] ], + "Loot": [ + { "Item": "Tier5Mob.2", "Weight": 20 }, + { "Item": "ShardT3", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 3, + "Drops": [ [0, 45], [1, 51], [2, 3], [3, 1] ], + "Loot": [ + { "Item": "Tier5Mob.3", "Weight": 20 }, + { "Item": "ShardT3", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 4, + "Drops": [ [0, 25], [1, 69], [2, 4], [3, 2] ], + "Loot": [ + { "Item": "Tier5Mob.4", "Weight": 20 }, + { "Item": "ShardT3", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 5, + "Drops": [ [0, 5], [1, 87], [2, 5], [3, 3] ], + "Loot": [ + { "Item": "Tier5Mob.5", "Weight": 20 }, + { "Item": "ShardT3", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 6, + "Drops": [ [0, 0], [1, 86], [2, 10], [3, 4] ], + "Loot": [ + { "Item": "Tier5Mob.6", "Weight": 20 }, + { "Item": "ShardT3", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + } + ] + }, + { + "Object": "Tier6EliteMob", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 70], [1, 28], [2, 2] ], + "Loot": [ + { "Item": "Tier6Mob.1", "Weight": 20 }, + { "Item": "ShardT4", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 2, + "Drops": [ [0, 50], [1, 46], [2, 3], [3, 1] ], + "Loot": [ + { "Item": "Tier6Mob.2", "Weight": 20 }, + { "Item": "ShardT4", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 3, + "Drops": [ [0, 30], [1, 64], [2, 4], [3, 2] ], + "Loot": [ + { "Item": "Tier6Mob.3", "Weight": 20 }, + { "Item": "ShardT4", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 4, + "Drops": [ [0, 5], [1, 87], [2, 5], [3, 3] ], + "Loot": [ + { "Item": "Tier6Mob.4", "Weight": 20 }, + { "Item": "ShardT4", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 5, + "Drops": [ [0, 0], [1, 86], [2, 10], [3, 4] ], + "Loot": [ + { "Item": "Tier6Mob.5", "Weight": 20 }, + { "Item": "ShardT4", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 6, + "Drops": [ [0, 0], [1, 80], [2, 15], [3, 5] ], + "Loot": [ + { "Item": "Tier6Mob.6", "Weight": 20 }, + { "Item": "ShardT4", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + } + ] + }, + { + "Object": "Tier7EliteMob", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 65], [1, 32], [2, 3] ], + "Loot": [ + { "Item": "Tier7Mob.1", "Weight": 20 }, + { "Item": "ShardT5", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 2, + "Drops": [ [0, 45], [1, 51], [2, 3], [3, 1] ], + "Loot": [ + { "Item": "Tier7Mob.2", "Weight": 20 }, + { "Item": "ShardT5", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 3, + "Drops": [ [0, 25], [1, 69], [2, 4], [3, 2] ], + "Loot": [ + { "Item": "Tier7Mob.3", "Weight": 20 }, + { "Item": "ShardT5", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 4, + "Drops": [ [0, 5], [1, 87], [2, 5], [3, 3] ], + "Loot": [ + { "Item": "Tier7Mob.4", "Weight": 20 }, + { "Item": "ShardT5", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 5, + "Drops": [ [0, 0], [1, 86], [2, 10], [3, 4] ], + "Loot": [ + { "Item": "Tier7Mob.5", "Weight": 20 }, + { "Item": "ShardT5", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 6, + "Drops": [ [0, 0], [1, 80], [2, 15], [3, 5] ], + "Loot": [ + { "Item": "Tier7Mob.6", "Weight": 20 }, + { "Item": "ShardT5", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + } + ] + }, + { + "Object": "Tier8EliteMob", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 60], [1, 36], [2, 4] ], + "Loot": [ + { "Item": "Tier8Mob.1", "Weight": 20 }, + { "Item": "ShardT6", "Weight": 1 }, + { "Item": "ShardT7", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 2, + "Drops": [ [0, 40], [1, 46], [2, 3], [3, 1] ], + "Loot": [ + { "Item": "Tier8Mob.2", "Weight": 20 }, + { "Item": "ShardT6", "Weight": 1 }, + { "Item": "ShardT7", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 3, + "Drops": [ [0, 20], [1, 64], [2, 4], [3, 2] ], + "Loot": [ + { "Item": "Tier8Mob.3", "Weight": 20 }, + { "Item": "ShardT6", "Weight": 1 }, + { "Item": "ShardT7", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 4, + "Drops": [ [0, 5], [1, 87], [2, 5], [3, 3] ], + "Loot": [ + { "Item": "Tier8Mob.4", "Weight": 20 }, + { "Item": "ShardT6", "Weight": 1 }, + { "Item": "ShardT7", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 5, + "Drops": [ [0, 0], [1, 86], [2, 10], [3, 4] ], + "Loot": [ + { "Item": "Tier8Mob.5", "Weight": 20 }, + { "Item": "ShardT6", "Weight": 1 }, + { "Item": "ShardT7", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + }, + { + "Level": 6, + "Drops": [ [0, 0], [1, 80], [2, 15], [3, 5] ], + "Loot": [ + { "Item": "Tier8Mob.6", "Weight": 20 }, + { "Item": "ShardT6", "Weight": 1 }, + { "Item": "ShardT7", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.2 } + ] + } + ] + }, { "Object": "Greyling", "RefObject": "Tier0Mob" @@ -1020,7 +1548,7 @@ }, { "Object": "Troll", - "RefObject": "Tier3Mob" + "RefObject": "Tier3EliteMob" }, { "Object": "Bjorn", @@ -1052,7 +1580,7 @@ }, { "Object": "Abomination", - "RefObject": "Tier4Mob" + "RefObject": "Tier4EliteMob" }, { "Object": "Ulv", @@ -1080,7 +1608,7 @@ }, { "Object": "StoneGolem", - "RefObject": "Tier5Mob" + "RefObject": "Tier5EliteMob" }, { "Object": "Fenring", @@ -1108,7 +1636,7 @@ }, { "Object": "GoblinBrute", - "RefObject": "Tier6Mob" + "RefObject": "Tier6EliteMob" }, { "Object": "GoblinShaman", @@ -1116,7 +1644,7 @@ }, { "Object": "Unbjorn", - "RefObject": "Tier6Mob" + "RefObject": "Tier6EliteMob" }, { "Object": "Tick", @@ -1128,7 +1656,7 @@ }, { "Object": "SeekerBrute", - "RefObject": "Tier7Mob" + "RefObject": "Tier7EliteMob" }, { "Object": "Dverger", @@ -1152,7 +1680,7 @@ }, { "Object": "Gjall", - "RefObject": "Tier7Mob" + "RefObject": "Tier7EliteMob" }, { "Object": "Volture", @@ -1184,15 +1712,15 @@ }, { "Object": "FallenValkyrie", - "RefObject": "Tier8Mob" + "RefObject": "Tier8EliteMob" }, { "Object": "Morgen", - "RefObject": "Tier8Mob" + "RefObject": "Tier8EliteMob" }, { "Object": "Morgen_NonSleeping", - "RefObject": "Tier8Mob" + "RefObject": "Tier8EliteMob" }, { "Object": "Eikthyr", @@ -1243,6 +1771,19 @@ } ] }, + { + "Object": "Eikthyr", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Eikthyr_Rare_ShardStone", "Weight": 1, "Rarity": [ 0, 70, 25, 5, 0 ], + "RarityItems": { "Rare": "Eikthyr_Rare_ShardStone", "Epic": "Eikthyr_Epic_ShardStone", "Legendary": "Eikthyr_Legendary_ShardStone", "Mythic": "Eikthyr_Mythic_ShardStone" } } + ] + } + ] + }, { "Object": "gd_king", "LeveledLoot": [ @@ -1290,6 +1831,19 @@ } ] }, + { + "Object": "gd_king", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Elder_Rare_ShardStone", "Weight": 1, "Rarity": [ 0, 60, 30, 10, 0 ], + "RarityItems": { "Rare": "Elder_Rare_ShardStone", "Epic": "Elder_Epic_ShardStone", "Legendary": "Elder_Legendary_ShardStone", "Mythic": "Elder_Mythic_ShardStone" } } + ] + } + ] + }, { "Object": "Bonemass", "LeveledLoot": [ @@ -1337,6 +1891,19 @@ } ] }, + { + "Object": "Bonemass", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Bonemass_Epic_ShardStone", "Weight": 1, "Rarity": [ 0, 0, 70, 25, 5 ], + "RarityItems": { "Epic": "Bonemass_Epic_ShardStone", "Legendary": "Bonemass_Legendary_ShardStone", "Mythic": "Bonemass_Mythic_ShardStone" } } + ] + } + ] + }, { "Object": "Dragon", "LeveledLoot": [ @@ -1384,6 +1951,19 @@ } ] }, + { + "Object": "Dragon", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Moder_Epic_ShardStone", "Weight": 1, "Rarity": [ 0, 0, 60, 30, 10 ], + "RarityItems": { "Epic": "Moder_Epic_ShardStone", "Legendary": "Moder_Legendary_ShardStone", "Mythic": "Moder_Mythic_ShardStone" } } + ] + } + ] + }, { "Object": "GoblinKing", "LeveledLoot": [ @@ -1431,6 +2011,19 @@ } ] }, + { + "Object": "GoblinKing", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Yagluth_Legendary_ShardStone", "Weight": 1, "Rarity": [ 0, 0, 0, 80, 20 ], + "RarityItems": { "Legendary": "Yagluth_Legendary_ShardStone", "Mythic": "Yagluth_Mythic_ShardStone" } } + ] + } + ] + }, { "Object": "SeekerQueen", "LeveledLoot": [ @@ -1478,6 +2071,19 @@ } ] }, + { + "Object": "SeekerQueen", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Queen_Legendary_ShardStone", "Weight": 1, "Rarity": [ 0, 0, 0, 70, 30 ], + "RarityItems": { "Legendary": "Queen_Legendary_ShardStone", "Mythic": "Queen_Mythic_ShardStone" } } + ] + } + ] + }, { "Object": "Fader", "LeveledLoot": [ @@ -1525,6 +2131,18 @@ } ] }, + { + "Object": "Fader", + "LeveledLoot": [ + { + "Level": 1, + "Drops": [ [0, 50], [1, 50] ], + "Loot": [ + { "Item": "Fader_Mythic_ShardStone", "Weight": 1, "Rarity": [ 0, 0, 0, 0, 100 ] } + ] + } + ] + }, { "Object": "TreasureChest_meadows", "Drops": [ [0, 78], [1, 20], [2, 2] ], @@ -1591,7 +2209,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier2Everything", "Weight": 3, "Rarity": [ 80, 17, 3, 0, 0] }, - { "Item": "Tier3Everything", "Weight": 1, "Rarity": [ 80, 17, 3, 0, 0] } + { "Item": "Tier3Everything", "Weight": 1, "Rarity": [ 80, 17, 3, 0, 0] }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1599,7 +2218,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier2Everything", "Weight": 5 }, - { "Item": "Tier3Everything", "Weight": 1 } + { "Item": "Tier3Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1607,7 +2227,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier3Everything", "Weight": 4 }, - { "Item": "Tier4Everything", "Weight": 1 } + { "Item": "Tier4Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1615,7 +2236,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier3Everything", "Weight": 4 }, - { "Item": "Tier4Everything", "Weight": 1 } + { "Item": "Tier4Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1630,7 +2252,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier4Everything", "Weight": 3 }, - { "Item": "Tier5Everything", "Weight": 1 } + { "Item": "Tier5Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1638,7 +2261,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier5Everything", "Weight": 3 }, - { "Item": "Tier6Everything", "Weight": 1 } + { "Item": "Tier6Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1660,7 +2284,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier6Everything", "Weight": 3 }, - { "Item": "Tier7Everything", "Weight": 1 } + { "Item": "Tier7Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1668,7 +2293,8 @@ "Drops": [ [0, 58], [1, 30], [2, 20], [3, 2] ], "Loot": [ { "Item": "Tier6Everything", "Weight": 3 }, - { "Item": "Tier7Everything", "Weight": 1 } + { "Item": "Tier7Everything", "Weight": 1 }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1692,7 +2318,8 @@ "Drops": [ [3, 80], [4, 15], [5, 5] ], "Loot": [ { "Item": "Tier2Everything", "Weight": 1, "Rarity": [ 10, 45, 50, 5, 0] }, - { "Item": "Tier3Everything", "Weight": 1, "Rarity": [ 10, 45, 50, 5, 0] } + { "Item": "Tier3Everything", "Weight": 1, "Rarity": [ 10, 45, 50, 5, 0] }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1700,7 +2327,8 @@ "Drops": [ [3, 80], [4, 15], [5, 5] ], "Loot": [ { "Item": "Tier3Everything", "Weight": 1, "Rarity": [ 0, 30, 60, 10, 0] }, - { "Item": "Tier4Everything", "Weight": 1, "Rarity": [ 0, 30, 60, 10, 0] } + { "Item": "Tier4Everything", "Weight": 1, "Rarity": [ 0, 30, 60, 10, 0] }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1708,7 +2336,8 @@ "Drops": [ [3, 80], [4, 15], [5, 5] ], "Loot": [ { "Item": "Tier4Everything", "Weight": 1, "Rarity": [ 0, 10, 75, 14, 1] }, - { "Item": "Tier5Everything", "Weight": 1, "Rarity": [ 0, 10, 75, 14, 1] } + { "Item": "Tier5Everything", "Weight": 1, "Rarity": [ 0, 10, 75, 14, 1] }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1716,7 +2345,8 @@ "Drops": [ [3, 80], [4, 15], [5, 5] ], "Loot": [ { "Item": "Tier5Everything", "Weight": 1, "Rarity": [ 0, 5, 70, 20, 5] }, - { "Item": "Tier6Everything", "Weight": 1, "Rarity": [ 0, 5, 70, 20, 5] } + { "Item": "Tier6Everything", "Weight": 1, "Rarity": [ 0, 5, 70, 20, 5] }, + { "Item": "ShardUnique", "Weight": 0.02 } ] }, { @@ -1724,7 +2354,8 @@ "Drops": [ [3, 80], [4, 15], [5, 5] ], "Loot": [ { "Item": "Tier6Everything", "Weight": 1, "Rarity": [ 0, 0, 65, 25, 10] }, - { "Item": "Tier7Everything", "Weight": 1, "Rarity": [ 0, 0, 65, 25, 10] } + { "Item": "Tier7Everything", "Weight": 1, "Rarity": [ 0, 0, 65, 25, 10] }, + { "Item": "ShardUnique", "Weight": 0.02 } ] } ] diff --git a/EpicLoot/config/materialconversions.json b/EpicLoot/config/materialconversions.json index db9920b45..3820fd621 100755 --- a/EpicLoot/config/materialconversions.json +++ b/EpicLoot/config/materialconversions.json @@ -1,4 +1,4 @@ -{ +{ "MaterialConversions" : [ { "Name": "Recipe_ShardRareUpgrade", @@ -195,6 +195,56 @@ { "Item": "Coins", "Amount": 15 } ] }, + { + "Name": "Recipe_MagicDustAndEssenceToShard_Conversion", + "Product": "ShardMagic", + "Amount": 1, + "Type": "Convert", + "Resources": [ + { "Item": "EssenceMagic", "Amount": 2 }, + { "Item": "DustMagic", "Amount": 2 } + ] + }, + { + "Name": "Recipe_RareDustAndEssenceToShard_Conversion", + "Product": "ShardRare", + "Amount": 1, + "Type": "Convert", + "Resources": [ + { "Item": "EssenceRare", "Amount": 2 }, + { "Item": "DustRare", "Amount": 2 } + ] + }, + { + "Name": "Recipe_EpicDustAndEssenceToShard_Conversion", + "Product": "ShardEpic", + "Amount": 1, + "Type": "Convert", + "Resources": [ + { "Item": "EssenceEpic", "Amount": 2 }, + { "Item": "DustEpic", "Amount": 2 } + ] + }, + { + "Name": "Recipe_LegendaryDustAndEssenceToShard_Conversion", + "Product": "ShardLegendary", + "Amount": 1, + "Type": "Convert", + "Resources": [ + { "Item": "EssenceLegendary", "Amount": 2 }, + { "Item": "DustLegendary", "Amount": 2 } + ] + }, + { + "Name": "Recipe_MythicDustAndEssenceToShard_Conversion", + "Product": "ShardMythic", + "Amount": 1, + "Type": "Convert", + "Resources": [ + { "Item": "EssenceMythic", "Amount": 2 }, + { "Item": "DustMythic", "Amount": 2 } + ] + }, { "Name": "Recipe_MagicShardToDust_Conversion", "Product": "DustMagic", diff --git a/EpicLoot/config/recipes.json b/EpicLoot/config/recipes.json deleted file mode 100755 index 21f80a3be..000000000 --- a/EpicLoot/config/recipes.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "recipes": [ - { - "name": "Recipe_LeatherBelt", - "item": "LeatherBelt", - "craftingStation": "forge", - "resources": [ - { "item": "LeatherScraps", "amount": 4 }, - { "item": "Bronze", "amount": 1 } - ] - }, - { - "name": "Recipe_SilverRing", - "item": "SilverRing", - "craftingStation": "forge", - "resources": [ - { "item": "Silver", "amount": 1 } - ] - }, - { - "name": "Recipe_GoldRubyRing", - "item": "GoldRubyRing", - "craftingStation": "forge", - "resources": [ - { "item": "Coins", "amount": 200 }, - { "item": "Ruby", "amount": 1 } - ] - } - ] -} \ No newline at end of file diff --git a/EpicLoot/config/shardstoneconversions.json b/EpicLoot/config/shardstoneconversions.json new file mode 100644 index 000000000..c8e69016a --- /dev/null +++ b/EpicLoot/config/shardstoneconversions.json @@ -0,0 +1,4005 @@ +{ + "MaterialConversions": [ + { + "Name": "ShardStoneUpgrade_Red_Magic_to_Rare", + "Product": "Red_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Red_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Red_Rare_to_Epic", + "Product": "Red_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Red_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Red_Epic_to_Legendary", + "Product": "Red_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Red_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Red_Legendary_to_Mythic", + "Product": "Red_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Red_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Yellow_Magic_to_Rare", + "Product": "Yellow_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Yellow_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Yellow_Rare_to_Epic", + "Product": "Yellow_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Yellow_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Yellow_Epic_to_Legendary", + "Product": "Yellow_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Yellow_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Yellow_Legendary_to_Mythic", + "Product": "Yellow_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Yellow_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Cyan_Magic_to_Rare", + "Product": "Cyan_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Cyan_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Cyan_Rare_to_Epic", + "Product": "Cyan_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Cyan_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Cyan_Epic_to_Legendary", + "Product": "Cyan_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Cyan_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Cyan_Legendary_to_Mythic", + "Product": "Cyan_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Cyan_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Orange_Magic_to_Rare", + "Product": "Orange_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Orange_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Orange_Rare_to_Epic", + "Product": "Orange_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Orange_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Orange_Epic_to_Legendary", + "Product": "Orange_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Orange_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Orange_Legendary_to_Mythic", + "Product": "Orange_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Orange_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Pink_Magic_to_Rare", + "Product": "Pink_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Pink_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Pink_Rare_to_Epic", + "Product": "Pink_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Pink_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Pink_Epic_to_Legendary", + "Product": "Pink_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Pink_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Pink_Legendary_to_Mythic", + "Product": "Pink_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Pink_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Black_Magic_to_Rare", + "Product": "Black_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Black_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Black_Rare_to_Epic", + "Product": "Black_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Black_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Black_Epic_to_Legendary", + "Product": "Black_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Black_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Black_Legendary_to_Mythic", + "Product": "Black_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Black_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_White_Magic_to_Rare", + "Product": "White_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "White_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_White_Rare_to_Epic", + "Product": "White_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "White_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_White_Epic_to_Legendary", + "Product": "White_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "White_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_White_Legendary_to_Mythic", + "Product": "White_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "White_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Green_Magic_to_Rare", + "Product": "Green_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Green_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Green_Rare_to_Epic", + "Product": "Green_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Green_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Green_Epic_to_Legendary", + "Product": "Green_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Green_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Green_Legendary_to_Mythic", + "Product": "Green_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Green_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Purple_Magic_to_Rare", + "Product": "Purple_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Purple_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Purple_Rare_to_Epic", + "Product": "Purple_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Purple_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Purple_Epic_to_Legendary", + "Product": "Purple_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Purple_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Purple_Legendary_to_Mythic", + "Product": "Purple_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Purple_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Grey_Magic_to_Rare", + "Product": "Grey_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Grey_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Grey_Rare_to_Epic", + "Product": "Grey_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Grey_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Grey_Epic_to_Legendary", + "Product": "Grey_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Grey_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Grey_Legendary_to_Mythic", + "Product": "Grey_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Grey_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkRed_Magic_to_Rare", + "Product": "DarkRed_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkRed_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkRed_Rare_to_Epic", + "Product": "DarkRed_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkRed_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkRed_Epic_to_Legendary", + "Product": "DarkRed_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkRed_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkRed_Legendary_to_Mythic", + "Product": "DarkRed_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkRed_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkGreen_Magic_to_Rare", + "Product": "DarkGreen_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkGreen_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkGreen_Rare_to_Epic", + "Product": "DarkGreen_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkGreen_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkGreen_Epic_to_Legendary", + "Product": "DarkGreen_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkGreen_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkGreen_Legendary_to_Mythic", + "Product": "DarkGreen_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkGreen_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkBlue_Magic_to_Rare", + "Product": "DarkBlue_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkBlue_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkBlue_Rare_to_Epic", + "Product": "DarkBlue_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkBlue_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkBlue_Epic_to_Legendary", + "Product": "DarkBlue_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkBlue_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkBlue_Legendary_to_Mythic", + "Product": "DarkBlue_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkBlue_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkPurple_Magic_to_Rare", + "Product": "DarkPurple_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkPurple_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkPurple_Rare_to_Epic", + "Product": "DarkPurple_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkPurple_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkPurple_Epic_to_Legendary", + "Product": "DarkPurple_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkPurple_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_DarkPurple_Legendary_to_Mythic", + "Product": "DarkPurple_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "DarkPurple_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Golden_Magic_to_Rare", + "Product": "Golden_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Golden_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Golden_Rare_to_Epic", + "Product": "Golden_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Golden_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Golden_Epic_to_Legendary", + "Product": "Golden_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Golden_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Golden_Legendary_to_Mythic", + "Product": "Golden_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Golden_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightBlue_Magic_to_Rare", + "Product": "LightBlue_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightBlue_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightBlue_Rare_to_Epic", + "Product": "LightBlue_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightBlue_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightBlue_Epic_to_Legendary", + "Product": "LightBlue_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightBlue_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightBlue_Legendary_to_Mythic", + "Product": "LightBlue_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightBlue_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightGreen_Magic_to_Rare", + "Product": "LightGreen_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightGreen_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightGreen_Rare_to_Epic", + "Product": "LightGreen_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightGreen_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightGreen_Epic_to_Legendary", + "Product": "LightGreen_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightGreen_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_LightGreen_Legendary_to_Mythic", + "Product": "LightGreen_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "LightGreen_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Peach_Magic_to_Rare", + "Product": "Peach_Rare_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Peach_Magic_ShardStone", "Amount": 1 }, + { "Item": "ShardMagic", "Amount": 4 } + ] + }, + { + "Name": "ShardStoneUpgrade_Peach_Rare_to_Epic", + "Product": "Peach_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Peach_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 } + ] + }, + { + "Name": "ShardStoneUpgrade_Peach_Epic_to_Legendary", + "Product": "Peach_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Peach_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 } + ] + }, + { + "Name": "ShardStoneUpgrade_Peach_Legendary_to_Mythic", + "Product": "Peach_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Peach_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 } + ] + }, + { + "Name": "ShardStoneUpgrade_Firewalker_Epic_to_Legendary", + "Product": "Firewalker_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Firewalker_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 }, + { "Item": "RunestoneEpic", "Amount": 2 } + ] + }, + { + "Name": "ShardStoneUpgrade_Firewalker_Legendary_to_Mythic", + "Product": "Firewalker_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Firewalker_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "RunestoneLegendary", "Amount": 2 } + ] + }, + { + "Name": "ShardStoneUpgrade_Stormcaller_Epic_to_Legendary", + "Product": "Stormcaller_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Stormcaller_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 }, + { "Item": "RunestoneEpic", "Amount": 2 } + ] + }, + { + "Name": "ShardStoneUpgrade_Stormcaller_Legendary_to_Mythic", + "Product": "Stormcaller_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Stormcaller_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "RunestoneLegendary", "Amount": 2 } + ] + }, + { + "Name": "ShardStoneUpgrade_Eikthyr_Rare_to_Epic", + "Product": "Eikthyr_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Eikthyr_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 }, + { "Item": "TrophyEikthyr", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Eikthyr_Epic_to_Legendary", + "Product": "Eikthyr_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Eikthyr_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 }, + { "Item": "TrophyEikthyr", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Eikthyr_Legendary_to_Mythic", + "Product": "Eikthyr_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Eikthyr_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "TrophyEikthyr", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Elder_Rare_to_Epic", + "Product": "Elder_Epic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Elder_Rare_ShardStone", "Amount": 1 }, + { "Item": "ShardRare", "Amount": 5 }, + { "Item": "TrophyTheElder", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Elder_Epic_to_Legendary", + "Product": "Elder_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Elder_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 }, + { "Item": "TrophyTheElder", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Elder_Legendary_to_Mythic", + "Product": "Elder_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Elder_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "TrophyTheElder", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Bonemass_Epic_to_Legendary", + "Product": "Bonemass_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Bonemass_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 }, + { "Item": "TrophyBonemass", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Bonemass_Legendary_to_Mythic", + "Product": "Bonemass_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Bonemass_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "TrophyBonemass", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Moder_Epic_to_Legendary", + "Product": "Moder_Legendary_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Moder_Epic_ShardStone", "Amount": 1 }, + { "Item": "ShardEpic", "Amount": 6 }, + { "Item": "TrophyDragonQueen", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Moder_Legendary_to_Mythic", + "Product": "Moder_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Moder_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "TrophyDragonQueen", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Yagluth_Legendary_to_Mythic", + "Product": "Yagluth_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Yagluth_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "TrophyGoblinKing", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneUpgrade_Queen_Legendary_to_Mythic", + "Product": "Queen_Mythic_ShardStone", + "Amount": 1, + "Type": "Upgrade", + "Resources": [ + { "Item": "Queen_Legendary_ShardStone", "Amount": 1 }, + { "Item": "ShardLegendary", "Amount": 7 }, + { "Item": "TrophySeekerQueen", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Red_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Red_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yellow_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yellow_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Cyan_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Cyan_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Orange_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Orange_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Pink_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Pink_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Black_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Black_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_White_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "White_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Green_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Green_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Purple_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Purple_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Grey_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Grey_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkRed_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkRed_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkGreen_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkGreen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkBlue_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkBlue_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_DarkPurple_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "DarkPurple_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Golden_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Golden_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightBlue_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightBlue_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_LightGreen_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "LightGreen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Magic_to_Dust", + "Product": "DustMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Magic_to_Reagent", + "Product": "ReagentMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Magic_to_Essence", + "Product": "EssenceMagic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Magic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Peach_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Peach_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Firewalker_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Firewalker_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Stormcaller_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Stormcaller_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Eikthyr_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Eikthyr_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Rare_to_Dust", + "Product": "DustRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Rare_to_Reagent", + "Product": "ReagentRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Rare_to_Essence", + "Product": "EssenceRare", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Rare_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Elder_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Elder_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Bonemass_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Bonemass_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Epic_to_Dust", + "Product": "DustEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Epic_to_Reagent", + "Product": "ReagentEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Epic_to_Essence", + "Product": "EssenceEpic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Epic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Moder_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Moder_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yagluth_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yagluth_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yagluth_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yagluth_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yagluth_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yagluth_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yagluth_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yagluth_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yagluth_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yagluth_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Yagluth_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Yagluth_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Queen_Legendary_to_Dust", + "Product": "DustLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Queen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Queen_Legendary_to_Reagent", + "Product": "ReagentLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Queen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Queen_Legendary_to_Essence", + "Product": "EssenceLegendary", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Queen_Legendary_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Queen_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Queen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Queen_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Queen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Queen_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Queen_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Fader_Mythic_to_Dust", + "Product": "DustMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Fader_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Fader_Mythic_to_Reagent", + "Product": "ReagentMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Fader_Mythic_ShardStone", "Amount": 1 } + ] + }, + { + "Name": "ShardStoneConvert_Fader_Mythic_to_Essence", + "Product": "EssenceMythic", + "Amount": 2, + "Type": "Convert", + "Resources": [ + { "Item": "Fader_Mythic_ShardStone", "Amount": 1 } + ] + } + ] +} diff --git a/EpicLoot/config/shardstones.json b/EpicLoot/config/shardstones.json new file mode 100644 index 000000000..f73e9bf54 --- /dev/null +++ b/EpicLoot/config/shardstones.json @@ -0,0 +1,2195 @@ +{ + "Shards": { + "Red": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "LifeGainOnHit", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "RangedWeapon": { + "EffectType": "LifeGainOnHit", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "MagicWeapon": { + "EffectType": "HealthOnEitrUse", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 6, + "Epic": 9, + "Legendary": 12, + "Mythic": 15 + } + }, + "Shield": { + "EffectType": "LifeGainOnBlock", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Head": { + "EffectType": "ModifyHealthRegen", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "PercentHealth", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Legs": { + "EffectType": "IncreaseHealth", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Trinket": { + "EffectType": "DamageTakenGivesAdrenaline", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Utility": { + "EffectType": "AddHealthRegen", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Shoulders": { + "EffectType": "BulkUp", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + } + } + }, + "Yellow": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "ModifyAttackStaminaUse", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "ModifyDrawStaminaUse", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "EnergeticEitr", + "ValuesPerRarity": { + "Magic": 6, + "Rare": 8, + "Epic": 10, + "Legendary": 12, + "Mythic": 14 + } + }, + "Shield": { + "EffectType": "StaminaGainOnBlock", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Head": { + "EffectType": "ModifyStaminaRegen", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Chest": { + "EffectType": "PercentStamina", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "IncreaseStamina", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Trinket": { + "EffectType": "UseAdrenalineAsStamina", + "ValuesPerRarity": { + "Magic": 20, + "Rare": 40, + "Epic": 60, + "Legendary": 80, + "Mythic": 100 + } + }, + "Utility": { + "EffectType": "ModifySprintStaminaUse", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "StaminaOnKill", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 3.5, + "Epic": 4, + "Legendary": 4.5, + "Mythic": 5 + } + } + } + }, + "Cyan": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "EitrImbueAttack", + "ValuesPerRarity": { + "Magic": 9, + "Rare": 11, + "Epic": 13, + "Legendary": 15, + "Mythic": 17 + } + }, + "RangedWeapon": { + "EffectType": "EitrImbueAttack", + "ValuesPerRarity": { + "Magic": 15, + "Rare": 20, + "Epic": 25, + "Legendary": 30, + "Mythic": 35 + } + }, + "MagicWeapon": { + "EffectType": "ModifyAttackEitrUse", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Shield": { + "EffectType": "EitrGainOnBlock", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Head": { + "EffectType": "PercentEitr", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Chest": { + "EffectType": "IncreaseEitr", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Legs": { + "EffectType": "ModifyEitrRegen", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Trinket": { + "EffectType": "EitrUseGivesAdrenaline", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "EitrShield", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + }, + "Shoulders": { + "EffectType": "HeartyEitr", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + } + } + }, + "Orange": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "AddFireDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "AddFireDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "AddFireDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "PhysToFireOnBlock", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Head": { + "EffectType": "AddFireResistancePercentage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "PhysToFire", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + }, + "Legs": { + "EffectType": "Kindling", + "ValuesPerRarity": { + "Magic": 6, + "Rare": 9, + "Epic": 12, + "Legendary": 15, + "Mythic": 18 + } + }, + "Trinket": { + "EffectType": "BurningAdrenaline", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "IncreaseHeatResistance", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "BurningSpeed", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 9, + "Epic": 13, + "Legendary": 17, + "Mythic": 20 + } + } + } + }, + "Pink": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "PerfectDodgeGivesStamina", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "PerfectDodgeGivesStamina", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "PerfectDodgeGivesEitr", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "BlockAsDodgeAsBlock", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 6, + "Epic": 9, + "Legendary": 12, + "Mythic": 15 + } + }, + "Head": { + "EffectType": "DecreaseDodgeCost", + "ValuesPerRarity": { + "Magic": 15, + "Rare": 20, + "Epic": 25, + "Legendary": 30, + "Mythic": 35 + } + }, + "Chest": { + "EffectType": "ReduceFallDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Legs": { + "EffectType": "DodgeBuff", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Trinket": { + "EffectType": "PerfectDodge", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Utility": { + "EffectType": "RollCleanse", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Shoulders": { + "EffectType": "PerfectDodgeGivesSpeed", + "ValuesPerRarity": { + "Magic": 20, + "Rare": 40, + "Epic": 60, + "Legendary": 80, + "Mythic": 100 + } + } + } + }, + "Black": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "IncreaseDamageDuringNighttime", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 7, + "Epic": 11, + "Legendary": 15, + "Mythic": 18 + } + }, + "RangedWeapon": { + "EffectType": "IncreaseDamageDuringNighttime", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 7, + "Epic": 11, + "Legendary": 15, + "Mythic": 18 + } + }, + "MagicWeapon": { + "EffectType": "IncreaseDamageDuringNighttime", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 7, + "Epic": 11, + "Legendary": 15, + "Mythic": 18 + } + }, + "Shield": { + "EffectType": "NightBlocker", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + }, + "Head": { + "EffectType": "NightStaminaRegenIncrease", + "ValuesPerRarity": { + "Magic": 12, + "Rare": 16, + "Epic": 20, + "Legendary": 24, + "Mythic": 28 + } + }, + "Chest": { + "EffectType": "DamageReductionAtNight", + "ValuesPerRarity": { + "Magic": 7, + "Rare": 9, + "Epic": 11, + "Legendary": 13, + "Mythic": 15 + } + }, + "Legs": { + "EffectType": "AddKnivesSkill", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Trinket": { + "EffectType": "SummonBatWhenActivatingAdrenaline", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "ModifyNoise", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Shoulders": { + "EffectType": "NightCarryWeight", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + } + } + }, + "White": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "IncreaseDamageDuringDaytime", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "RangedWeapon": { + "EffectType": "IncreaseDamageDuringDaytime", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "MagicWeapon": { + "EffectType": "IncreaseDamageDuringDaytime", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Shield": { + "EffectType": "DayBlocker", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Head": { + "EffectType": "DayDiscovery", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + }, + "Chest": { + "EffectType": "DayArmor", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Legs": { + "EffectType": "DayStaminaRegen", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 5, + "Epic": 6, + "Legendary": 7, + "Mythic": 8 + } + }, + "Trinket": { + "EffectType": "DayHealthRegen", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 2.5, + "Epic": 3, + "Legendary": 3.5, + "Mythic": 4 + } + }, + "Utility": { + "EffectType": "AddCrafterSkills", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Shoulders": { + "EffectType": "DaySailingSpeed", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 8, + "Epic": 11, + "Legendary": 14, + "Mythic": 17 + } + } + } + }, + "Green": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "DamageIncreaseFromMovementPenalty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "DamageIncreaseFromMovementPenalty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "DamageIncreaseFromMovementPenalty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "AnchoredBlock", + "ValuesPerRarity": { + "Magic": 0.25, + "Rare": 0.50, + "Epic": 0.75, + "Legendary": 1, + "Mythic": 1.25 + } + }, + "Head": { + "EffectType": "IncreaseXPGainFromMovementPenalty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "CarryWeightForMovementPenalty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "StaminaIncreaseForMovementPenalty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Trinket": { + "EffectType": "AddMovementSkills", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Utility": { + "EffectType": "ModifyJumpStaminaUse", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "ArmorFromMovementPenalty", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 8, + "Epic": 11, + "Legendary": 14, + "Mythic": 17 + } + } + } + }, + "Purple": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "EitrLeech", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "RangedWeapon": { + "EffectType": "EitrLeech", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "MagicWeapon": { + "EffectType": "ModifyMagicFireRate", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 12, + "Epic": 14, + "Legendary": 16, + "Mythic": 18 + } + }, + "Shield": { + "EffectType": "ElementalWarding", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 8, + "Epic": 12, + "Legendary": 16, + "Mythic": 20 + } + }, + "Head": { + "EffectType": "DartingThoughts", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "ConsumeEitrFirstForBloodCosts", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "EveryXPointsOfEitrIncreasesStamina", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Trinket": { + "EffectType": "ConvertEitrCostToStaminaCost", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "RunningOnEmpty", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "ReduceEitrCost", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 7.5, + "Epic": 10, + "Legendary": 12.5, + "Mythic": 15 + } + } + } + }, + "Grey": { + "Category": "Core", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "IncreaseHarvestDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "IncreaseHarvestDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "IncreaseHarvestDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "BlockAsWoodCuttingAndPickaxes", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Head": { + "EffectType": "IncreaseMiningDrop", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Chest": { + "EffectType": "AddFishingSkill", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Legs": { + "EffectType": "IncreaseTreeDrop", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Trinket": { + "EffectType": "GainAdrenalineFromHarvesting", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "IncreaseHarvestXPGain", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "ReduceFishingStaminaCost", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 18, + "Epic": 25, + "Legendary": 33, + "Mythic": 40 + } + } + } + }, + "DarkRed": { + "Category": "Dark", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "IncreaseMeleeSkills", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 4, + "Epic": 5, + "Legendary": 6, + "Mythic": 7 + } + }, + "RangedWeapon": { + "EffectType": "IncreaseRangedSkills", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "MagicWeapon": { + "EffectType": "AddBluntDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "BloodBaseBlock", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 6, + "Epic": 9, + "Legendary": 12, + "Mythic": 15 + } + }, + "Head": { + "EffectType": "HeadHunter", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "Bloodrage", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Legs": { + "EffectType": "BloodDrinker", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 6, + "Epic": 9, + "Legendary": 12, + "Mythic": 15 + } + }, + "Trinket": { + "EffectType": "AdrenalineCharge", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 4, + "Epic": 5, + "Legendary": 6, + "Mythic": 7 + } + }, + "Utility": { + "EffectType": "OffSetAttack", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "ReduceArmorIncreaseDamage", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 9, + "Epic": 13, + "Legendary": 17, + "Mythic": 20 + } + } + } + }, + "DarkGreen": { + "Category": "Dark", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "AddPoisonDamage", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 20, + "Epic": 30, + "Legendary": 40, + "Mythic": 50 + } + }, + "RangedWeapon": { + "EffectType": "AddPoisonDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "AddPoisonDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "PhysToPoisonOnBlock", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Head": { + "EffectType": "AddPoisonResistancePercentage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "PhysToPoison", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "AddBlockingSkill", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Trinket": { + "EffectType": "GainAdrenalineWhenApplyingPoison", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Utility": { + "EffectType": "IncreaseAllPoisonDamageDone", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "PoisonToTrueDamage", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + } + } + }, + "DarkBlue": { + "Category": "Dark", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "AddFrostDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "AddFrostDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "AddFrostDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "PhysToFrostOnBlock", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Head": { + "EffectType": "AddFrostResistancePercentage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "PhysToFrost", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "AddElementalMagicSkill", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Trinket": { + "EffectType": "AdrenalineFrostWave", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Utility": { + "EffectType": "Warmth", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "IcyWeight", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 8, + "Epic": 11, + "Legendary": 14, + "Mythic": 17 + } + } + } + }, + "DarkPurple": { + "Category": "Dark", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "ModifyAttackHealthUse", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "RangedWeapon": { + "EffectType": "ModifyAttackHealthUse", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "MagicWeapon": { + "EffectType": "ModifyAttackHealthUse", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Shield": { + "EffectType": "BloodStaggerBlock", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 10, + "Epic": 15, + "Legendary": 20, + "Mythic": 25 + } + }, + "Head": { + "EffectType": "KillsReduceNextBloodCost", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "ReflectDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "BloodMagicLevelIncreasesHealthRegen", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 5, + "Epic": 6, + "Legendary": 7, + "Mythic": 8 + } + }, + "Trinket": { + "EffectType": "GainAdrenalineWhenSacrificingHealth", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "AddBloodMagicSkill", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Shoulders": { + "EffectType": "GainEitrWhenSacrificingHealth", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + } + } + }, + "Golden": { + "Category": "Dark", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "ChanceDoubleDamage", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 5, + "Epic": 7, + "Legendary": 9, + "Mythic": 11 + } + }, + "RangedWeapon": { + "EffectType": "ChanceDoubleDamage", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 5, + "Epic": 7, + "Legendary": 9, + "Mythic": 11 + } + }, + "MagicWeapon": { + "EffectType": "ChanceDoubleDamage", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 5, + "Epic": 7, + "Legendary": 9, + "Mythic": 11 + } + }, + "Shield": { + "EffectType": "LuckyBlock", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Shield": { + "EffectType": "StaggerOnBlock", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Head": { + "EffectType": "Inspiration", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 15, + "Epic": 20, + "Legendary": 25, + "Mythic": 30 + } + }, + "Chest": { + "EffectType": "LuckyLoot", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Legs": { + "EffectType": "LuckWhileFishing", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 10, + "Epic": 15, + "Legendary": 20, + "Mythic": 25 + } + }, + "Trinket": { + "EffectType": "Luck", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Utility": { + "EffectType": "Riches", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "LuckyCraft", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + } + } + }, + "LightBlue": { + "Category": "Light", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "AddLightningDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "AddLightningDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "AddLightningDamage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "PhysToLightningOnBlock", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "Head": { + "EffectType": "AddLightningResistancePercentage", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Chest": { + "EffectType": "PhysToLightning", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "StormRider", + "ValuesPerRarity": { + "Magic": 10, + "Rare": 14, + "Epic": 18, + "Legendary": 22, + "Mythic": 26 + } + }, + "Trinket": { + "EffectType": "StormFury", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 1.5, + "Epic": 2, + "Legendary": 2.5, + "Mythic": 3 + } + }, + "Utility": { + "EffectType": "ConvertPhysicalDamageToLightning", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "Conduit", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + } + } + }, + "LightGreen": { + "Category": "Light", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "HealthGainPerXDamageDone", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "RangedWeapon": { + "EffectType": "HealthGainPerXDamageDone", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 4, + "Epic": 5, + "Legendary": 6, + "Mythic": 7 + } + }, + "MagicWeapon": { + "EffectType": "HealthGainPerXDamageDone", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Shield": { + "EffectType": "Warding", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 8, + "Epic": 12, + "Legendary": 16, + "Mythic": 20 + } + }, + "Head": { + "EffectType": "PotionEfficacy", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Chest": { + "EffectType": "Comfortable", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Legs": { + "EffectType": "AddPickaxesSkill", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Trinket": { + "EffectType": "AdrenalineIncreasesHealthRegen", + "ValuesPerRarity": { + "Magic": 16, + "Rare": 20, + "Epic": 26, + "Legendary": 32, + "Mythic": 40 + } + }, + "Utility": { + "EffectType": "BountifulHarvest", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shoulders": { + "EffectType": "RestingHealthRegen", + "ValuesPerRarity": { + "Magic": 20, + "Rare": 40, + "Epic": 60, + "Legendary": 80, + "Mythic": 100 + } + } + } + }, + "Peach": { + "Category": "Light", + "Rarities": [ + "Magic", + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "TypeEffects": { + "MeleeWeapon": { + "EffectType": "DamageBonusFromPlayerWeight", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "RangedWeapon": { + "EffectType": "DamageBonusFromPlayerWeight", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "MagicWeapon": { + "EffectType": "DamageBonusFromPlayerWeight", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Shield": { + "EffectType": "BurdenedBlock", + "ValuesPerRarity": { + "Magic": 0.25, + "Rare": 0.50, + "Epic": 0.75, + "Legendary": 1, + "Mythic": 1.25 + } + }, + "Head": { + "EffectType": "GainMaxStaminaBasedOnPlayerMaxHealth", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Chest": { + "EffectType": "StaminaRegenBonusFromPlayerWeight", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Legs": { + "EffectType": "GainMaxCarryWeightFromRested", + "ValuesPerRarity": { + "Magic": 1, + "Rare": 2, + "Epic": 3, + "Legendary": 4, + "Mythic": 5 + } + }, + "Trinket": { + "EffectType": "SailingSpeed", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 3, + "Epic": 4, + "Legendary": 5, + "Mythic": 6 + } + }, + "Utility": { + "EffectType": "AddCarryWeight", + "ValuesPerRarity": { + "Magic": 2, + "Rare": 4, + "Epic": 6, + "Legendary": 8, + "Mythic": 10 + } + }, + "Shoulders": { + "EffectType": "TravelLight", + "ValuesPerRarity": { + "Magic": 3, + "Rare": 6, + "Epic": 9, + "Legendary": 12, + "Mythic": 15 + } + } + } + }, + "Firewalker": { + "Category": "Unique", + "Rarities": [ + "Epic", + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "Trailblazer", + "ValuesPerRarity": { + "Epic": 8, + "Legendary": 12, + "Mythic": 16 + } + }, + "TypeEffects": {} + }, + "Stormcaller": { + "Category": "Unique", + "Rarities": [ + "Epic", + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "StrikeCausesLightning", + "ValuesPerRarity": { + "Epic": 5, + "Legendary": 8, + "Mythic": 12 + } + }, + "TypeEffects": {} + }, + "Eikthyr": { + "Category": "Boss", + "Rarities": [ + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "ShockingCharge", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 10, + "Epic": 15, + "Legendary": 20, + "Mythic": 25 + } + }, + "TypeEffects": {} + }, + "Elder": { + "Category": "Boss", + "Rarities": [ + "Rare", + "Epic", + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "ForestsAid", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 10, + "Epic": 15, + "Legendary": 20, + "Mythic": 25 + } + }, + "TypeEffects": {} + }, + "Bonemass": { + "Category": "Boss", + "Rarities": [ + "Epic", + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "CorpseRot", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 10, + "Epic": 15, + "Legendary": 20, + "Mythic": 25 + } + }, + "TypeEffects": {} + }, + "Moder": { + "Category": "Boss", + "Rarities": [ + "Epic", + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "IcyRetribution", + "ValuesPerRarity": { + "Magic": 5, + "Rare": 10, + "Epic": 15, + "Legendary": 20, + "Mythic": 25 + } + }, + "TypeEffects": {} + }, + "Yagluth": { + "Category": "Boss", + "Rarities": [ + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "MeteorSummoner", + "ValuesPerRarity": { + "Magic": 4, + "Rare": 6, + "Epic": 8, + "Legendary": 10, + "Mythic": 12 + } + }, + "TypeEffects": {} + }, + "Queen": { + "Category": "Boss", + "Rarities": [ + "Legendary", + "Mythic" + ], + "UniformEffect": { + "EffectType": "Everflow", + "ValuesPerRarity": { + "Magic": 7, + "Rare": 8, + "Epic": 9, + "Legendary": 10, + "Mythic": 11 + } + }, + "TypeEffects": {} + }, + "Fader": { + "Category": "Boss", + "Rarities": [ + "Mythic" + ], + "UniformEffect": { + "EffectType": "NecroticFire", + "ValuesPerRarity": { + "Magic": 0.2, + "Rare": 0.4, + "Epic": 0.6, + "Legendary": 0.8, + "Mythic": 1.0 + } + }, + "TypeEffects": {} + } + } +} \ No newline at end of file diff --git a/EpicLoot/create_update_language_files.rb b/EpicLoot/create_update_language_files.rb deleted file mode 100644 index 7ab3b44c5..000000000 --- a/EpicLoot/create_update_language_files.rb +++ /dev/null @@ -1,199 +0,0 @@ -# frozen_string_literal: true - -require "openai" - -##### -# This script can be used to generate translations (or missing chunks of translations) for each of the 26 languages valheim supports -# it is suggested to run this on Ruby 3.4.5+ -# it requires the openai gem `gem install ruby-openai`, it also requires a valid OpenAI key to be placed in a .env file (in the same directroy) -# the .env file is gitignored and not present in the repo. -# this script can be ran like: `ruby create_update_language_files.rb` -# -# - -file = File.open(".env") -config = { - skip_existing: false -} -localizations_dir = "localizations" -file.readlines.map(&:chomp).each do |secret| - secret_details = secret.split("=") - config[secret_details[0]] = secret_details[1] -end - -def sanitize_response(text, original_text) - remove_newline = !original_text.include?("\n") - sanitized_response = text.gsub("\"", "").gsub("”", "").gsub("“", "").gsub("#{original_text}", "").gsub("Translation:", "") - # Format a bunch of the various ways chatgpt might respond - sanitized_response = sanitized_response.gsub("\n", "") if remove_newline - sanitized_response = sanitized_response[3..] if sanitized_response[0..2] == " - " || sanitized_response[0..2] == " = " - sanitized_response = sanitized_response[4..] if sanitized_response[0..3] == " -> " - sanitized_response = sanitized_response.chomp - return sanitized_response -end - -client = OpenAI::Client.new(access_token: config["OPENAI_KEY"], log_errors: true) - -master_file = File.read("#{localizations_dir}/English.json") -master_json = JSON.parse(master_file) -target_keys = master_json.keys.count -languages = %w[ - Swedish - French - Italian - German - Spanish - Russian - Romanian - Bulgarian - Macedonian - Finnish - Danish - Norwegian - Icelandic - Turkish - Lithuanian - Czech - Hungarian - Slovak - Polish - Dutch - Portuguese_European - Portuguese_Brazilian - Chinese - Chinese_Trad - Japanese - Korean - Hindi - Thai - Croatian - Georgian - Greek - Serbian - Ukrainian - Latvian -] -languages.each do |lang| - puts "Starting translation work for #{lang}" - current_language_translation = {} - keys_needed = [] - need_translation_updates = false - if File.file?("#{localizations_dir}/#{lang}.json") && config[:skip_existing] == false - current_language_translation = JSON.parse(File.read("#{localizations_dir}/#{lang}.json").force_encoding("utf-8")) - if target_keys > current_language_translation.keys.count - need_translation_updates = true - puts "Translation updated needed for #{lang} (#{target_keys} > #{current_language_translation.keys.count})" - end - if need_translation_updates - master_json.each_key do |key| - next if current_language_translation.key?(key) - - keys_needed << key - end - end - next if need_translation_updates == false - - keys_needed.each do |key| - result = false - translation = "" - loop do - response = client.chat( - parameters: { - model: "gpt-3.5-turbo-1106", # Required. - messages: [{ role: "user", content: "Please translate the quoted phrase to #{lang} and respond only the translation text: \"#{master_json[key]}\"" }], - temperature: 0.7 - } - ) - translation = sanitize_response(response["choices"][0]["message"]["content"], master_json[key]) - result = true unless translation.empty? - - break if result == true - end - puts "Translated: #{master_json[key]} -> | #{translation} |" - current_language_translation[key] = translation - end - # This truncates the existing file and rewrites it with the new larger json structure - File.open("#{localizations_dir}/#{lang}.json", "w") { |f| f.write(JSON.pretty_generate(current_language_translation)) } - next - end - - puts "Starting on language translation for: #{lang}" - - # Have to chunk the requests because max request size is 16385 - sanitized = "" - if (target_keys * 100 > 1600) - json_chunk = {} - response_chunks = {} - requestsize = 0 - counts = 0 - master_json.each do |k,v| - counts += 1 - requestsize += k.length - requestsize += v.length - json_chunk[k] = v - # Send the request and reset the page, or send the last batch - if (requestsize >= 1600 || counts >= target_keys) - puts "Built request with #{json_chunk.keys.count} keys" - response = client.chat( - parameters: { - model: "gpt-3.5-turbo-1106", # Required. - response_format: { type: "json_object" }, - messages: [{ role: "user", content: "Please convert the following JSON to #{lang}: #{json_chunk}" }], - temperature: 0.7 - }) - json_chunk.clear - requestsize = 0 - sanitized = response["choices"][0]["message"]["content"].force_encoding("utf-8") - begin - JSON.parse(sanitized).each do |k, v| - response_chunks[k] = v - end - rescue StandardError # rubocop:disable Lint/SuppressedException - Puts "Error deserializing batch, re-run to generate missing translations." - end - end - end - sanitized = JSON.generate(response_chunks) - else - response = client.chat( - parameters: { - model: "gpt-4.1-mini", # Required. - response_format: { type: "json_object" }, - messages: [{ role: "user", content: "Please convert the following JSON to #{lang}: #{master_file}" }], - temperature: 0.7 - } - ) - puts "Recieved response for #{lang}, writing to language file. choices #{response['choices'].length}" - sanitized = response["choices"][0]["message"]["content"].force_encoding("utf-8") - end - - - - file_completed = false - begin - language_json = JSON.parse(sanitized) - File.write("#{localizations_dir}/#{lang}.json", JSON.pretty_generate(language_json)) - file_completed = true - rescue StandardError # rubocop:disable Lint/SuppressedException - end - next if file_completed - - # do line-by-line translation to avoid JSON issues - translated_hash = {} - master_json.each do |key, value| - response = client.chat( - parameters: { - model: "gpt-3.5-turbo-1106", # Required. - messages: [{ role: "user", content: "Please translate the quoted phrase to #{lang} and respond only the translation text: \"#{value}\"" }], - temperature: 0.7 - } - ) - # Chatgpt likes to respond with the original string also, and likes to quote/not quote inconsistently - # puts "Original response: #{response["choices"][0]["message"]["content"]}" - - sanitized_response = sanitize_response(response["choices"][0]["message"]["content"], value) - puts "Line response: #{sanitized_response}" - translated_hash[key.to_sym] = response["choices"][0]["message"]["content"].gsub("\"", "").force_encoding("utf-8") - end - File.write("#{localizations_dir}/#{lang}.json", JSON.pretty_generate(translated_hash)) -end diff --git a/EpicLoot/docs/API.md b/EpicLoot/docs/API.md new file mode 100644 index 000000000..db00df7c7 --- /dev/null +++ b/EpicLoot/docs/API.md @@ -0,0 +1,327 @@ +# Epic Loot integration API + +A supported contract for other plugins, so integrating with Epic Loot does not mean Harmony-patching its +internals and re-breaking on every release. + +There are two layers, and you can use either: + +| | What it is | When to use it | +|---|---|---| +| **`EpicLoot.API`** | A `static partial class` in `EpicLoot.dll`. The actual ABI. | You are comfortable with `Type.GetType("EpicLoot.API, EpicLoot")` and reflection, or you already have your own glue. | +| **`EpicLootAPI.dll`** | A small shim that wraps every endpoint in typed methods and does the reflection for you. Bundle it with ILRepack. | Almost always. See [the shim README](../../EpicLootAPI/EpicLootAPI/README.md) for the ILRepack target. | + +Epic Loot's plugin GUID is `randyknapp.mods.epicloot`. Declare it as a soft dependency: + +```csharp +[BepInDependency("randyknapp.mods.epicloot", BepInDependency.DependencyFlags.SoftDependency)] +``` + +--- + +## The signature rule + +Nothing but primitives, `string`, vanilla/Unity types (`Player`, `ItemDrop.ItemData`, `Vector3`, +`UnityEngine.Object`) and `System.Func`/`Action` built from those ever crosses this boundary. **No Epic +Loot type appears in any signature.** Structured data travels as JSON strings; rarity travels as an `int`. + +That is what lets you call the API with no assembly reference at all, and what keeps an internal refactor +from breaking your build. + +Two consequences worth knowing before you write glue by hand: + +- **`ref`, not `out`.** The shim's reflection transport reads mutated arguments back out of the `object[]` + that `MethodInfo.Invoke` fills in, which does not work with `out`. Endpoints like `TryGetRarity` take + `ref int rarity`. The typed shim presents them as normal `out` parameters. +- **Rarity is an `int`.** `0`=Magic, `1`=Rare, `2`=Epic, `3`=Legendary, `4`=Mythic. Call + `GetRarityCount()` rather than hard-coding 5. + +## Versioning + +```csharp +API.ApiVersion // const int, currently 1 +API.GetApiVersion() // same value, as a call +API.GetPluginVersion() // "0.13.0" +API.GetPluginId() // "randyknapp.mods.epicloot" +API.HasEndpoint(name) // feature probe for a specific method +API.GetEndpointNames() // every public endpoint, for diagnostics +``` + +`ApiVersion` is bumped on every additive change and is independent of the plugin version. If you rely on +something newer than the oldest Epic Loot you support, gate it on `HasEndpoint`. Through the shim, a +missing endpoint logs a warning and no-ops rather than throwing. + +Epic Loot runs `NetworkCompatibility(EveryoneMustHaveMod, VersionStrictness.Patch)` — every client and the +server must run the same patch version. The shim is not subject to that; bundle whichever version you +built against. + +--- + +## Migration table + +Every row is something at least one released mod does today. The left column is unsupported and has +broken before. + +| Instead of patching / reflecting | Call | +|---|---| +| `EpicLoot_UnityLib.InventoryManagement.GetAllItems` / `HasItem` / `CountItem` / `RemoveItem` / `RemoveExactItem` | `RegisterInventoryProvider` | +| `EpicLoot.PlayerExtensions.GetEquipment` (deprecated — internals call `GetMagicEquipment`, so this patch does nothing) | `RegisterEquipmentProvider` | +| `EpicLoot.Crafting.EnchantCostsHelper.GetSacrificeProducts` (prefix returning false) | `RegisterSacrificeFilter` | +| `EpicLoot.Crafting.EnchantTabController.GetEnchantCosts` (the type no longer exists) | `GetEnchantCostsJson` | +| `EpicLoot.EpicLoot.GetRarityColor` + enumerating the `ItemRarity` enum | `GetRarityColorByIndex`, `GetRarityCount` | +| `EpicLoot.ItemDataExtensions.IsMagic` / `GetRarity` | `IsMagicItem`, `TryGetRarity` | +| `m_shared.m_name.StartsWith("$mod_epicloot")` | `IsEpicLootItem` | +| `LootRoller.RollMagicItem` / `RollEffect` / `InitializeMagicItem` / `GetLuckFactor` via a publicized DLL | `TryMakeMagicItem`, `RollMagicItemJson`, `GetLuckFactor` | +| `UniqueLegendaryHelper.TryGetLegendaryInfo` / `GetSetForLegendaryItem` | `GetLegendaryInfoJson`, `GetLegendaryIDs` | +| `MagicItemComponent.SetMagicItem` | `ApplyMagicItemJson`, or `TryMakeMagicItem` | +| `MagicItemEffectDefinitions.GetAvailableEffects` / `AllDefinitions` | `GetAvailableEffectTypes`, `GetAllMagicEffectTypes` | +| Polling for "did this item change?" | `AddMagicItemChangedListener` | + +Two things are **not** promoted to the API and remain unsupported internals: `PatchOnHoverFix.AddScrollbar` +(UI plumbing) and reading Epic Loot's BepInEx config entries directly. + +--- + +## Queries + +All null-safe and non-throwing. Note that the underlying extension methods are not — `GetRarity` throws +for a non-magic item — which is why `TryGetRarity` exists. + +```csharp +bool IsMagicItem(ItemDrop.ItemData item); +bool TryGetRarity(ItemDrop.ItemData item, ref int rarity); +int GetRarityCount(); +string GetRarityColorByIndex(int rarity); // "#AA55FF" +string GetRarityDisplayNameByIndex(int rarity); // "$mod_epicloot_Epic" +string GetItemRarityColor(ItemDrop.ItemData item); // the colour this item's name is drawn in +bool IsEpicLootItem(ItemDrop.ItemData item); +bool IsShardStone(ItemDrop.ItemData item); +bool IsRunestone(ItemDrop.ItemData item); +bool IsMagicCraftingMaterial(ItemDrop.ItemData item); +bool IsUnidentified(ItemDrop.ItemData item); +bool CanBeMagicItem(ItemDrop.ItemData item); +bool ItemHasMagicEffect(ItemDrop.ItemData item, string effectType, bool includeSocketed); +List GetAllMagicEffectTypes(); +string GetEnchantCostsJson(ItemDrop.ItemData item, int rarity); // [{ "Item": prefab, "Amount": n }] +string GetSacrificeProductsJson(ItemDrop.ItemData item); +string GetMagicItemJson(ItemDrop.ItemData item); +``` + +`IsUnidentified` matters: an unidentified item reports `true` from `IsMagicItem`, but its effects are not +revealed yet and should not be shown or acted on. + +Through the shim these are extension methods: + +```csharp +using EpicLootAPI; + +if (!EpicLoot.IsLoaded()) return; + +if (item.IsMagicItem() && item.TryGetRarity(out ItemRarity rarity)) +{ + string colour = EpicLoot.GetRarityColor(rarity); +} +``` + +--- + +## Providers + +The core of the API: contribute to what Epic Loot considers available, instead of patching the methods +that ask. + +Every registered delegate runs inside a try/catch. One that throws is logged with your id — always, not +gated by Epic Loot's logging config, because it is your bug — and treated as contributing nothing. A +broken integration degrades to vanilla behaviour; it does not break the enchanting table. + +### Inventory providers + +Widen the enchanting table's view of the player's inventory: nearby containers, a backpack, a remote +stash. Epic Loot spends the player's own inventory first and only charges the shortfall to providers, in +registration order. + +```csharp +API.RegisterInventoryProvider( + id: "my.plugin.guid", + getItems: () => NearbyContainerItems(), // may be null + countItem: name => CountInContainers(name), // by m_shared.m_name + removeItem: (name, amount) => TakeFromContainers(name, amount), // returns amount removed + removeExactItem: (item, amount) => TakeExact(item, amount)); + +API.UnregisterInventoryProvider("my.plugin.guid"); +``` + +- `getItems` must return **live instances**, not copies. Epic Loot needs instance identity to preserve + magic data, and it de-duplicates against what the player already holds. +- `removeExactItem` must match **by reference**, not by name. Matching by name will consume the wrong + enchanted item. +- `removeItem` / `removeExactItem` return how many they actually removed; returning more than requested is + clamped. + +### Equipment providers + +Contribute equipped items for magic effect resolution — extra equipment slots, quick slots, an equipped +backpack. These feed `PlayerExtensions.GetMagicEquipment`, so contributed items count toward effect +totals, legendary set bonuses, tooltips and shard socketing alike. Non-magic items are filtered out for +you, so returning everything is fine. + +```csharp +API.RegisterEquipmentProvider("my.plugin.guid", player => MyExtraSlots(player)); +``` + +Contributed items also get their equip effect visuals — the auras and particle effects worn on the +player, such as `Glowing`. Those are reconciled against `GetMagicEquipment` after every equipment +change, so an item in your slot lights up exactly like one in a vanilla slot; you do not need to attach +or remove anything yourself. + +Epic Loot memoizes effect totals per player and invalidates only on vanilla `Humanoid.EquipItem` / +`UnequipItem`. If your slots change outside those methods, tell it: + +```csharp +API.InvalidatePlayerEffectCache(player); +``` + +Without that call, stale totals keep being served after a slot change. The same call also refreshes the +worn equip effect visuals, so it is the one signal to send whenever your slot contents move. + +### Sacrifice filters + +Veto sacrificing specific items — typically one equipped in a slot Epic Loot cannot see, which would +otherwise be destroyed by accident. Returning `false` yields no sacrifice products, which hides the item +from the Sacrifice tab. + +```csharp +API.RegisterSacrificeFilter("my.plugin.guid", item => !IsInMyQuickSlot(item)); +``` + +Called for every item the tab evaluates, so keep it cheap. + +`API.GetRegisteredProviders()` returns the registered ids grouped by family, for diagnostics. + +--- + +## Drawing your own item slots + +Epic Loot decorates the vanilla inventory grid and hotkey bar with **transpilers**. If your plugin +reimplements `InventoryGrid.UpdateGui` or `HotkeyBar.UpdateIcons` — a prefix returning `false`, say — +the original body never runs, so neither does the decoration, and your slots show no rarity background. +A postfix on those methods cannot fix it for you: Epic Loot has no way to tell which of your elements +holds which item. + +Call this once per element instead, from wherever you fill it in: + +```csharp +API.ApplyMagicItemBackground(element.m_go, element.m_equiped, item, inventoryGrid: false); +``` + +- Pass `null` for `item` to clear a slot you are emptying. +- `inventoryGrid: true` also draws the legendary set marker; `false` is the hotbar treatment. +- Child images are created the first time a slot actually needs one, so it is safe to call every frame + and calling it for empty slots costs nothing. + +--- + +## Events + +Add and remove listeners with plain method calls, so a reflection-only consumer can subscribe without +binding to an event field. + +```csharp +API.AddMagicItemChangedListener((item, reason) => { /* ... */ }); +API.AddLootGeneratedListener(item => { /* ... */ }); +API.AddBountyCompletedListener((player, monsterId) => { /* ... */ }); +// plus a matching Remove* for each +``` + +`OnMagicItemChanged` fires on **every** magic-data write, with a reason token from `API.ChangeReason`: + +| Token | Raised by | +|---|---| +| `Enchant` | Enchanting an item at the table | +| `Augment` | Locking in or applying an augment | +| `Disenchant` | Sacrificing a magic item (the component is dropped) | +| `Rune` | Rune extract, rune enhance, and the item reduction that follows an extract | +| `Temper` | A tempering success or failure | +| `Socket` / `Unsocket` | Adding or removing a shard/runestone | +| `LootRoll` | A magic item rolled as loot, including via `TryMakeMagicItem` | +| `Transfer` | Magic effects carried onto a crafted item | +| `Unspecified` | A write with no dedicated call site | + +`Unspecified` is the catch-all raised from `MagicItemComponent.SetMagicItem`, the single funnel every +magic-data write passes through — so coverage is complete even for paths that have no semantic token. +Writes that happen while an item is *loading* do not raise anything; otherwise every item entering the +world would notify you at world load. + +The item's new magic data is already committed when your listener runs. A listener that throws is logged +and does not stop the other listeners or the game action. + +--- + +## Loot generation + +```csharp +bool TryMakeMagicItem(ItemDrop.ItemData item, int rarity, float luck, string legendaryID); +string RollMagicItemJson(int rarity, ItemDrop.ItemData item, float luck); +bool ApplyMagicItemJson(ItemDrop.ItemData item, string magicItemJson); +float GetLuckFactor(Vector3 position); +int RollEffectCountForRarity(int rarity); +List GetLegendaryIDs(int rarity); +string GetLegendaryInfoJson(string legendaryID); +List GetAvailableEffectTypes(ItemDrop.ItemData item, string magicItemJson, int rarity); +string AddLootTables(string json); +bool UpdateLootTables(string key, string json); +``` + +`TryMakeMagicItem` is the one-call path: it reproduces the whole drop flow — effect selection, socket +count, legendary/mythic assignment, randomized wear, display name — and raises both `LootRoll` and +`OnLootGenerated`. Check `CanBeMagicItem` first. Passing an unknown `legendaryID`, or one whose +requirements do not fit the item, **fails the roll** rather than silently downgrading it. + +To inspect before applying, use `RollMagicItemJson` then `ApplyMagicItemJson`. + +`AddLootTables` returns an opaque key; pass it back to `UpdateLootTables` to replace what you added. +Registrations are cached and re-applied whenever `loottables.json` reloads or a dedicated server pushes +its copy, so they survive both. (`LootRoller.Initialize` clears the table map, which is why the re-apply +matters — the same contract the content-registration endpoints have always had.) + +--- + +## Content registration + +The older half of the API — adding magic effects, abilities, legendaries and sets, material conversions, +sacrifices, bounty targets, secret stash items and treasure maps — is unchanged and documented with worked +examples in [the shim README](../../EpicLootAPI/EpicLootAPI/README.md). + +**Recipes are deprecated.** `AddRecipe`, `AddRecipes` and `UpdateRecipes` (and the shim's `CustomRecipe`) +are marked `[Obsolete]`: Epic Loot removed `recipes.json` in 0.13.0 and no longer ships a recipe config, +so there is nothing left for them to register into. Add recipes with Jotunn's `ItemManager` instead. + +Two hooks there are worth calling out because they take callbacks rather than JSON: + +- **`RegisterMagicEffectRequirement(name, predicate)`** — a custom gate for a magic effect, referenced from + the effect's `Requirements.ExternalRequirements`. Enforced on **every** path, including shard socketing + (unlike the built-in host-item gating, which socketing deliberately skips). +- **`RegisterProxyAbility(json, delegates)`** — an ability whose behaviour lives in your assembly. + +Registrations survive config reloads and server config pushes: the API caches them and re-applies them on +each `OnSetup*` event. + +There is also a non-code route that needs no plugin at all — the JSONPath patch engine reading +`/config/EpicLoot/patches/*.json`. See `config-patches/` for examples. Use it when you only need +to change data. + +--- + +## Testing your integration + +Epic Loot ships a console command that exercises the API from in-game: + +``` +magicapi version — API version, plugin version, endpoint and rarity list +magicapi query — every read-only endpoint against the equipped item +magicapi providers — registered inventory/equipment/sacrifice provider ids +magicapi events — toggle a logging listener for change and loot events +magicapi roll — TryMakeMagicItem on the equipped item +``` + +`magicapi events` writes to the BepInEx log, so you can confirm your listener would have fired and with +which reason token. diff --git a/EpicLoot/icon-patches.png b/EpicLoot/icon-patches.png new file mode 100644 index 000000000..0abdea8c0 Binary files /dev/null and b/EpicLoot/icon-patches.png differ diff --git a/EpicLoot/icon.psd b/EpicLoot/icon.psd new file mode 100644 index 000000000..dbd35dbf2 Binary files /dev/null and b/EpicLoot/icon.psd differ diff --git a/EpicLoot/image/EpicLoot-Header-ML.png b/EpicLoot/image/EpicLoot-Header-ML.png index 930a68468..4d699e50b 100644 Binary files a/EpicLoot/image/EpicLoot-Header-ML.png and b/EpicLoot/image/EpicLoot-Header-ML.png differ diff --git a/EpicLoot/image/EpicLoot-Tile-ML.png b/EpicLoot/image/EpicLoot-Tile-ML.png index 9e7a5e42c..fcb19bec5 100644 Binary files a/EpicLoot/image/EpicLoot-Tile-ML.png and b/EpicLoot/image/EpicLoot-Tile-ML.png differ diff --git a/EpicLoot/image/Epicloot_logo.afphoto b/EpicLoot/image/Epicloot_logo.afphoto new file mode 100644 index 000000000..04bcae680 Binary files /dev/null and b/EpicLoot/image/Epicloot_logo.afphoto differ diff --git a/EpicLoot/image/Epicloot_logo.png b/EpicLoot/image/Epicloot_logo.png new file mode 100644 index 000000000..660d273d4 Binary files /dev/null and b/EpicLoot/image/Epicloot_logo.png differ diff --git a/EpicLoot/info.md b/EpicLoot/info.md index 3cbe68dd2..4bd33fa09 100644 --- a/EpicLoot/info.md +++ b/EpicLoot/info.md @@ -24,7 +24,7 @@ Listen to the event `MagicItemEffectDefinitions.OnSetupMagicItemEffectDefinition * **Display Text:** This text appears in the tooltip for the magic item, with {0:?} replaced with the rolled value for the effect, formatted using the shown C# string format. * **Requirements:** A set of requirements. - * **Flags:** A set of predefined flags to check certain weapon properties. The list of flags is: `NoRoll, ExclusiveSelf, ItemHasPhysicalDamage, ItemHasElementalDamage, ItemUsesDurability, ItemHasNegativeMovementSpeedModifier, ItemHasBlockPower, ItemHasNoParryPower, ItemHasParryPower, ItemHasArmor, ItemHasBackstabBonus, ItemUsesStaminaOnAttack` + * **Flags:** A set of predefined flags to check certain weapon properties. The list of flags is: `NoRoll, ExclusiveSelf, ItemHasPhysicalDamage, ItemHasElementalDamage, ItemHasChopDamage, ItemUsesDurability, ItemHasNegativeMovementSpeedModifier, ItemHasBlockPower, ItemHasNoParryPower, ItemHasParryPower, ItemHasArmor, ItemHasBackstabBonus, ItemUsesStaminaOnAttack, ItemUsesEitrOnAttack, ItemUsesHealthOnAttack, ItemUsesDrawStaminaOnAttack, ItemGivesAdrenaline, ItemHasAdrenaline` * **ExclusiveEffectTypes:** This effect may not be rolled on an item that has already rolled on of these effects * **AllowedItemTypes:** This effect may only be rolled on items of a the types in this list. When this list is empty, this is usually done because this is a special effect type added programmatically or currently not allowed to roll. Options are: `Helmet, Chest, Legs, Shoulder, Utility, Bow, OneHandedWeapon, TwoHandedWeapon, TwoHandedWeaponLeft, Shield, Tool, Torch` * **ExcludedItemTypes:** This effect may only be rolled on items that are not one of the types on this list. @@ -34,7 +34,8 @@ Listen to the event `MagicItemEffectDefinitions.OnSetupMagicItemEffectDefinition * **ExcludedSkillTypes:** This effect may only be rolled on an item that does not use one of these skill types. * **AllowedItemNames:** This effect may only be rolled on an item with one of these names. Use the unlocalized shared name, i.e.: `$item_sword_iron` * **ExcludedItemNames:** This effect may only be rolled on an item that does not have one of these names. - * **CustomFlags:** A set of any arbitrary strings for future use + * **CustomFlags:** A set of arbitrary strings for future use. + * **ExternalRequirements:** Requirement keys registered by external plugins through `API.RegisterMagicEffectRequirement`. Every listed key must be registered and must return true for the effect to roll. * **Value Per Rarity:** This effect may only be rolled on items of a rarity included in this table. The value is rolled using a linear distribution between Min and Max and divisible by the Increment. ## DvergerCirclet @@ -7253,5 +7254,3 @@ A list of every built-in loot table from the mod. The name of the loot table is > | Items (lvl 6) | Weight (Chance) | Magic | Rare | Epic | Legendary | > | -- | -- | -- | -- | -- | -- | > | Tier6Everything | 1 (100%) | 0 (0%) | 0 (0%) | 65 (65%) | 35 (35%) | - - diff --git a/EpicLoot/localizations/Bulgarian.json b/EpicLoot/localizations/Bulgarian.json index afc8f297c..05af0bba2 100644 --- a/EpicLoot/localizations/Bulgarian.json +++ b/EpicLoot/localizations/Bulgarian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Търсене...", "mod_epicloot_no_active_adventures": "Няма активни приключения", "mod_epicloot_set_bonuses": "Комплектни бонуси", - "mod_epicloot_legendary_sets": "Легендарни сетове" + "mod_epicloot_legendary_sets": "Легендарни сетове", + "mod_epicloot_enchantments": "Омагьосвания", + "mod_epicloot_output": "Резултат", + "mod_epicloot_history": "История", + "mod_epicloot_requirements": "Изисквания", + "mod_epicloot_info": "Информация", + "mod_epicloot_min": "Мин", + "mod_epicloot_max": "Макс", + "mod_epicloot_increment": "Увеличение", + "mod_epicloot_minmax": "Мин/Макс", + "mod_epicloot_successchance": "Шанс за успех", + "mod_epicloot_criticalsuccess": "Критичен успех", + "mod_epicloot_criticalfail": "Критичен неуспех", + "mod_epicloot_temper": "Закаляване" } \ No newline at end of file diff --git a/EpicLoot/localizations/Chinese.json b/EpicLoot/localizations/Chinese.json index 49a10e637..542a2610c 100644 --- a/EpicLoot/localizations/Chinese.json +++ b/EpicLoot/localizations/Chinese.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "搜索...", "mod_epicloot_no_active_adventures": "没有正在进行的冒险", "mod_epicloot_set_bonuses": "套装加成", - "mod_epicloot_legendary_sets": "传奇套装" + "mod_epicloot_legendary_sets": "传奇套装", + "mod_epicloot_enchantments": "附魔", + "mod_epicloot_output": "产出", + "mod_epicloot_history": "历史", + "mod_epicloot_requirements": "需求", + "mod_epicloot_info": "信息", + "mod_epicloot_min": "最小", + "mod_epicloot_max": "最大", + "mod_epicloot_increment": "增量", + "mod_epicloot_minmax": "最小/最大", + "mod_epicloot_successchance": "成功率", + "mod_epicloot_criticalsuccess": "暴击成功", + "mod_epicloot_criticalfail": "暴击失败", + "mod_epicloot_temper": "淬炼" } \ No newline at end of file diff --git a/EpicLoot/localizations/Chinese_Trad.json b/EpicLoot/localizations/Chinese_Trad.json index 9711d0e8b..353daf811 100644 --- a/EpicLoot/localizations/Chinese_Trad.json +++ b/EpicLoot/localizations/Chinese_Trad.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "搜尋...", "mod_epicloot_no_active_adventures": "沒有進行中的冒險", "mod_epicloot_set_bonuses": "套裝加成", - "mod_epicloot_legendary_sets": "傳奇套裝" + "mod_epicloot_legendary_sets": "傳奇套裝", + "mod_epicloot_enchantments": "附魔", + "mod_epicloot_output": "產出", + "mod_epicloot_history": "歷史", + "mod_epicloot_requirements": "需求", + "mod_epicloot_info": "資訊", + "mod_epicloot_min": "最小", + "mod_epicloot_max": "最大", + "mod_epicloot_increment": "增量", + "mod_epicloot_minmax": "最小/最大", + "mod_epicloot_successchance": "成功率", + "mod_epicloot_criticalsuccess": "暴擊成功", + "mod_epicloot_criticalfail": "暴擊失敗", + "mod_epicloot_temper": "淬鍊" } \ No newline at end of file diff --git a/EpicLoot/localizations/Croatian.json b/EpicLoot/localizations/Croatian.json index f7c2c34b9..ee8858ec7 100644 --- a/EpicLoot/localizations/Croatian.json +++ b/EpicLoot/localizations/Croatian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Pretraži...", "mod_epicloot_no_active_adventures": "Nema aktivnih avantura", "mod_epicloot_set_bonuses": "Set bonusi", - "mod_epicloot_legendary_sets": "Legendarni setovi" + "mod_epicloot_legendary_sets": "Legendarni setovi", + "mod_epicloot_enchantments": "Začaranja", + "mod_epicloot_output": "Rezultat", + "mod_epicloot_history": "Povijest", + "mod_epicloot_requirements": "Zahtjevi", + "mod_epicloot_info": "Informacije", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Povećanje", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Šansa za uspjeh", + "mod_epicloot_criticalsuccess": "Kritični uspjeh", + "mod_epicloot_criticalfail": "Kritični neuspjeh", + "mod_epicloot_temper": "Kaljenje" } \ No newline at end of file diff --git a/EpicLoot/localizations/Czech.json b/EpicLoot/localizations/Czech.json index 5bc404a9a..dd574d596 100644 --- a/EpicLoot/localizations/Czech.json +++ b/EpicLoot/localizations/Czech.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Hledat...", "mod_epicloot_no_active_adventures": "Žádná aktivní dobrodružství", "mod_epicloot_set_bonuses": "Bonusy za set", - "mod_epicloot_legendary_sets": "Legendární sady" + "mod_epicloot_legendary_sets": "Legendární sady", + "mod_epicloot_enchantments": "Očarování", + "mod_epicloot_output": "Výstup", + "mod_epicloot_history": "Historie", + "mod_epicloot_requirements": "Požadavky", + "mod_epicloot_info": "Informace", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Přírůstek", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Šance na úspěch", + "mod_epicloot_criticalsuccess": "Kritický úspěch", + "mod_epicloot_criticalfail": "Kritické selhání", + "mod_epicloot_temper": "Kalení" } \ No newline at end of file diff --git a/EpicLoot/localizations/Danish.json b/EpicLoot/localizations/Danish.json index a62f56e30..89b45e2d9 100644 --- a/EpicLoot/localizations/Danish.json +++ b/EpicLoot/localizations/Danish.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Søg...", "mod_epicloot_no_active_adventures": "Ingen aktive eventyr", "mod_epicloot_set_bonuses": "Sætbonusser", - "mod_epicloot_legendary_sets": "Legendariske sæt" + "mod_epicloot_legendary_sets": "Legendariske sæt", + "mod_epicloot_enchantments": "Fortryllelser", + "mod_epicloot_output": "Resultat", + "mod_epicloot_history": "Historik", + "mod_epicloot_requirements": "Krav", + "mod_epicloot_info": "Information", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Forøgelse", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Succeschance", + "mod_epicloot_criticalsuccess": "Kritisk succes", + "mod_epicloot_criticalfail": "Kritisk fiasko", + "mod_epicloot_temper": "Hærdning" } \ No newline at end of file diff --git a/EpicLoot/localizations/Dutch.json b/EpicLoot/localizations/Dutch.json index 9ecf134ea..d64114086 100644 --- a/EpicLoot/localizations/Dutch.json +++ b/EpicLoot/localizations/Dutch.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Zoeken...", "mod_epicloot_no_active_adventures": "Geen actieve avonturen", "mod_epicloot_set_bonuses": "Setbonussen", - "mod_epicloot_legendary_sets": "Legendarische sets" + "mod_epicloot_legendary_sets": "Legendarische sets", + "mod_epicloot_enchantments": "Betoveringen", + "mod_epicloot_output": "Resultaat", + "mod_epicloot_history": "Geschiedenis", + "mod_epicloot_requirements": "Vereisten", + "mod_epicloot_info": "Informatie", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Verhoging", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Kans op succes", + "mod_epicloot_criticalsuccess": "Kritiek succes", + "mod_epicloot_criticalfail": "Kritieke mislukking", + "mod_epicloot_temper": "Harden" } \ No newline at end of file diff --git a/EpicLoot/localizations/English.json b/EpicLoot/localizations/English.json index 1a7760406..6c52dca72 100644 --- a/EpicLoot/localizations/English.json +++ b/EpicLoot/localizations/English.json @@ -2,18 +2,432 @@ "el_welcome_thank_you": "Thank you for playing", "el_welcome_version": "Epic Loot ", "el_welcome_content_pt1": "We hope you enjoy Epic loot! Epic loot is maintained by a community of modders and dedicated players, thank you to everyone that has contributed!", - "el_welcome_content_pt2": "There are some big changes since the last time you played Epic Loot Unidentified Items, Runes and config presets.", - "el_welcome_content_pt3": "You may discover the first two features as you progress through the game. To improve your experience, its recommended to pick an overhaul configuration now (default balanced).", - "el_welcome_content_pt4": "There are three available default overhaul configurations, which each providing a different level of power. Next to each is the expected damage and hp bonus you may find:\n - Minimal hp +(1 - 10), dmg +(1-6)% \n - Balanced hp +(1-18), dmg +(1-12)% \n - Legendary hp +(1-28), dmg +(1-20)%", + "el_welcome_content_pt2": "There are some big changes since the last time you played Epic Loot: Shardstones and Tempering.", + "el_welcome_content_pt3": "Shardstones drop across every biome and from bosses. They can be slotted into items with shard slots.\nTempering is a service Hildir provides, and lets you reroll the value of an enchantment you already have.", + "el_welcome_content_pt4": "To improve your experience, it's recommended to pick an overhaul configuration now (default balanced). Each provides a different level of power and a different mix of what drops. Next to each is the expected damage and hp bonus you may find:\n - Minimal hp +(1 - 10), dmg +(1-6)% \n - Balanced hp +(1-18), dmg +(1-12)% \n - Legendary hp +(1-28), dmg +(1-20)%\nAll of it can be changed later in the config.", "el_join_discord_button": "Join the discord!", "el_patch_notes_button": "View Patch Notes", "el_overhaul_legendary": "Legendary", "el_overhaul_balanced": "Balanced", "el_overhaul_minimal": "Minimal", - "mod_epicloot_craftingmaterial": "crafting material", + "el_menu_yes": "Yes", + "el_menu_no": "No", + "el_configupdate_title": "Epic Loot: Outdated Configs", + "el_configupdate_body": "Epic Loot has updated to version {0}. Configs you have not changed were refreshed automatically, but these have local changes and were left alone:\n\n{1}\n\nThey will not receive new content or balance changes while they differ from the defaults.\n\nReplace them with the new defaults? Your current files will be backed up to BepInEx/config/EpicLoot/baseconfig-backup first.\n\nChoosing No keeps your versions, and you will only be asked again if these configs change in a future update.", + + "mod_epicloot_itemtype_helmet": "Helmet", + "mod_epicloot_itemtype_chest": "Chest", + "mod_epicloot_itemtype_legs": "Legs", + "mod_epicloot_itemtype_shoulder": "Cape", + "mod_epicloot_itemtype_onehandedweapon": "One-Handed Weapon", + "mod_epicloot_itemtype_twohandedweapon": "Two-Handed Weapon", + "mod_epicloot_itemtype_twohandedweaponleft": "Two-Handed Weapon", + "mod_epicloot_itemtype_bow": "Bow", + "mod_epicloot_itemtype_shield": "Shield", + "mod_epicloot_itemtype_torch": "Torch", + "mod_epicloot_itemtype_utility": "Utility", + "mod_epicloot_itemtype_trinket": "Trinket", "mod_epicloot_set": "Set", + "mod_epicloot_craftingmaterial": "crafting material", + + "mod_epicloot_assets_shardstone": "Shardstone", + "mod_epicloot_assets_shardstone_introduce": "This shardstone looks like it would fit into a slot. Use Interact to insert shards.", + + "mod_epicloot_sockets": "Shard Slots", + "mod_epicloot_empty_socket": "Empty Shard Slot", + + "mod_epicloot_detail_range": "Roll range", + "mod_epicloot_config_max": "Cap", + "mod_epicloot_config_maxresistance": "Max Resistance", + "mod_epicloot_config_maxstacks": "Max Stacks", + "mod_epicloot_config_chance": "Chance", + "mod_epicloot_config_damage": "Damage Multiplier", + "mod_epicloot_config_projectiles": "Projectile Multiplier", + "mod_epicloot_config_accuracy": "Spread Multiplier", + "mod_epicloot_config_costscale": "Attack Cost Multiplier", + "mod_epicloot_config_coins": "Coins", + "mod_epicloot_config_amber": "Amber", + "mod_epicloot_config_amberpearl": "Amber Pearl", + "mod_epicloot_config_ruby": "Ruby", + "mod_epicloot_config_silvernecklace": "Silver Necklace", + "mod_epicloot_config_flint": "Flint", + "mod_epicloot_config_serpentscale": "Serpent Scale", + "mod_epicloot_config_triplechance": "Triple Catch Chance", + "mod_epicloot_config_secondspercent": "Seconds per 1%", + "mod_epicloot_config_procchance": "Proc Chance", + "mod_epicloot_config_bonusrollsmin": "Min Bonus Loot Rolls", + "mod_epicloot_config_bonusrollsmax": "Max Bonus Loot Rolls", + + "mod_epicloot_me_modifylowhealth_config_max": "Max Low Health Threshold", + "mod_epicloot_me_riches_config_coins": "Coin Value", + "mod_epicloot_me_riches_config_amber": "Amber Value", + "mod_epicloot_me_riches_config_amberpearl": "Amber Pearl Value", + "mod_epicloot_me_riches_config_ruby": "Ruby Value", + "mod_epicloot_me_riches_config_silvernecklace": "Silver Necklace Value", + "mod_epicloot_me_doublemagicshot_config_chance": "Extra Shot Chance", + "mod_epicloot_me_triplebowshot_config_chance": "Triple Shot Chance", + + "mod_epicloot_press_socket": "Open shard slots", + "mod_epicloot_socket_notmagic": "This item cannot be slotted", + "mod_epicloot_socket_unavailable": "This item's slots can't be opened right now", + "mod_epicloot_socket_nofreeslot": "No open slots", + "mod_epicloot_socket_invalidinput": "Only runestones or shards with an effect can be slotted", + "mod_epicloot_socket_notallowed": "This shard's effect can't be applied to this type of item", + "mod_epicloot_socket_conflict": "Conflicts with an existing enchantment", + "mod_epicloot_socket_missingreq": "This item is missing an enchantment this shard requires", + "mod_epicloot_socket_raritynotallowed": "This shard's effect can't be used at this item's rarity", + "mod_epicloot_socket_itemreq": "This item doesn't meet the requirements for this shard's effect", + "mod_epicloot_socket_generic": "This shard can't be slotted on this item", + "mod_epicloot_socket_duplicate": "That effect is already slotted", + "mod_epicloot_socket_nostackbinary": "This shard's effect can't be stacked", + "mod_epicloot_socket_tip_stacked": "({0}% strength)", + "mod_epicloot_socket_singlestackonly": "Split the stack first - only one shard fits in a slot", + "mod_epicloot_socket_bosslimit": "You can only use one boss shard at a time", + "mod_epicloot_socket_uniquelimit": "You can only use one unique shard at a time", + "mod_epicloot_socket_mustbreak": "This must be broken to be removed - right click to break it", + "mod_epicloot_socket_permanent": "This is set in place for good and cannot be removed", + "mod_epicloot_socket_break_title": "Break Slotted Stone?", + "mod_epicloot_socket_break_body": "{0} will be shattered and its slot emptied.\nThe stone is destroyed - this cannot be undone.", + "mod_epicloot_socket_tip_breakonly": "(break to remove)", + "mod_epicloot_socket_tip_permanent": "(permanent)", + "mod_epicloot_equip_bosslimit": "You are already wearing a boss shard", + "mod_epicloot_equip_uniquelimit": "You are already wearing a unique shard", + "mod_epicloot_shard_ifsocketed": "If slotted", + "mod_epicloot_shard_noeffect": "No effect", + "mod_epicloot_shard_allslots": "All slots", + "mod_epicloot_shard_bossexclusive": "Boss shard - only one may be worn at a time", + "mod_epicloot_shard_uniqueexclusive": "Unique shard - only one may be worn at a time", + "mod_epicloot_shard_warn_breakonly": "Once slotted, this shard must be broken to be removed", + "mod_epicloot_shard_warn_permanent": "Once slotted, this shard cannot be removed", + + "mod_epicloot_shardslot_meleeweapon": "Melee Weapon", + "mod_epicloot_shardslot_rangedweapon": "Ranged Weapon", + "mod_epicloot_shardslot_magicweapon": "Magic Weapon", + "mod_epicloot_shardslot_shield": "Shield", + "mod_epicloot_shardslot_armor": "Armor", + "mod_epicloot_shardslot_swords": "Sword", + "mod_epicloot_shardslot_axes": "Axe", + "mod_epicloot_shardslot_twohandaxes": "Battleaxe", + "mod_epicloot_shardslot_knives": "Knife", + "mod_epicloot_shardslot_fists": "Fist Weapon", + "mod_epicloot_shardslot_clubs": "Club", + "mod_epicloot_shardslot_sledges": "Sledge", + "mod_epicloot_shardslot_polearms": "Polearm", + "mod_epicloot_shardslot_spears": "Spear", + "mod_epicloot_shardslot_pickaxes": "Pickaxe", + "mod_epicloot_shardslot_tools": "Tool", + "mod_epicloot_shardslot_torches": "Torch", + "mod_epicloot_shardslot_bows": "Bow", + "mod_epicloot_shardslot_staffs": "Staff", + "mod_epicloot_shardslot_bucklers": "Buckler", + "mod_epicloot_shardslot_roundshields": "Round Shield", + "mod_epicloot_shardslot_towershields": "Tower Shield", + "mod_epicloot_shardslot_head": "Helmet", + "mod_epicloot_shardslot_chest": "Chest Armor", + "mod_epicloot_shardslot_legs": "Leg Armor", + "mod_epicloot_shardslot_shoulders": "Cape", + "mod_epicloot_shardslot_trinket": "Trinket", + "mod_epicloot_shardslot_utility": "Utility", + + "mod_epicloot_shard_Black": "Black", + "mod_epicloot_shard_Cyan": "Cyan", + "mod_epicloot_shard_Green": "Green", + "mod_epicloot_shard_Orange": "Orange", + "mod_epicloot_shard_Pink": "Pink", + "mod_epicloot_shard_Purple": "Purple", + "mod_epicloot_shard_Red": "Red", + "mod_epicloot_shard_White": "White", + "mod_epicloot_shard_Yellow": "Yellow", + "mod_epicloot_shard_DarkGreen": "Dark Green", + "mod_epicloot_shard_DarkPurple": "Dark Purple", + "mod_epicloot_shard_DarkRed": "Dark Red", + "mod_epicloot_shard_DarkBlue": "Dark Blue", + "mod_epicloot_shard_Golden": "Golden", + "mod_epicloot_shard_Grey": "Grey", + "mod_epicloot_shard_LightBlue": "Light Blue", + "mod_epicloot_shard_LightGreen": "Light Green", + "mod_epicloot_shard_Peach": "Peach", + "mod_epicloot_shard_Firewalker": "Firewalker", + "mod_epicloot_shard_Stormcaller": "Stormcaller", + "mod_epicloot_shard_Eikthyr": "Eikthyr", + "mod_epicloot_shard_Elder": "Elder", + "mod_epicloot_shard_Bonemass": "Bonemass", + "mod_epicloot_shard_Moder": "Moder", + "mod_epicloot_shard_Yagluth": "Yagluth", + "mod_epicloot_shard_Queen": "Queen", + "mod_epicloot_shard_Fader": "Fader", + + "mod_epicloot_me_damagetakengivesadrenaline_display": "Gain {0} Adrenaline When Damaged", + "mod_epicloot_me_damagetakengivesadrenaline_desc": "Gain {0} adrenaline each time you take damage.", + "mod_epicloot_me_useadrenalineasstamina_display": "Adrenaline as Stamina: {0}% Efficiency", + "mod_epicloot_me_useadrenalineasstamina_desc": "When stamina runs short, convert adrenaline into stamina at {0}% efficiency, spending up to your entire adrenaline pool.", + "mod_epicloot_me_eitrusegivesadrenaline_display": "Gain Adrenaline: {0}% of Eitr Spent", + "mod_epicloot_me_eitrusegivesadrenaline_desc": "Gain adrenaline equal to {0}% of the eitr you spend.", + "mod_epicloot_me_burningadrenaline_display": "Gain Adrenaline: {0}% of Fire Damage", + "mod_epicloot_me_burningadrenaline_desc": "Gain adrenaline equal to {0}% of the fire damage you deal.", + "mod_epicloot_me_gainadrenalinefromharvesting_display": "Gain {0} Adrenaline on Harvest", + "mod_epicloot_me_gainadrenalinefromharvesting_desc": "Gain {0} adrenaline each time you strike a tree or rock.", + "mod_epicloot_me_summonbatwhenactivatingadrenaline_display": "Summon a Bat at Full Adrenaline", + "mod_epicloot_me_summonbatwhenactivatingadrenaline_desc": "When your adrenaline fills, summon a bat to fight for you.", + "mod_epicloot_me_adrenalinefrostwave_display": "Frost Wave: Slow Enemies within {1}m for {0}s", + "mod_epicloot_me_adrenalinefrostwave_desc": "When your adrenaline fills, a wave of frost chills every enemy within {1} meters, slowing them for {0} seconds.", + "mod_epicloot_me_gainadrenalinewhenapplyingpoison_display": "Gain {0} Adrenaline every {1}s per Poisoned Foe Nearby (diminishing)", + "mod_epicloot_me_gainadrenalinewhenapplyingpoison_desc": "Every {1} seconds, gain {0} adrenaline for each enemy within {2}m suffering from your poison, with diminishing returns for each additional foe.", + "mod_epicloot_me_gainadrenalinewhensacrificinghealth_display": "Gain Adrenaline: {0}% of Health Spent", + "mod_epicloot_me_gainadrenalinewhensacrificinghealth_desc": "Gain adrenaline equal to {0}% of the health you spend on blood-magic costs.", + "mod_epicloot_me_stormfury_display": "+{0} Adrenaline every {1}s in Storms, No Adrenaline Decay", + "mod_epicloot_me_stormfury_desc": "Every {1} seconds while a storm rages, gain {0} adrenaline. Your adrenaline does not decay for as long as the storm lasts.", + "mod_epicloot_me_adrenalineincreaseshealthregen_display": "Adrenaline Surge: +{0}% Health Regen for {1}s", + "mod_epicloot_me_adrenalineincreaseshealthregen_desc": "When your adrenaline fills, gain +{0}% health regen for {1} seconds. Refilling refreshes the buff rather than stacking it.", + "mod_epicloot_me_adrenalinecharge_display": "-{0}% Forsaken Cooldown at Full Adrenaline", + "mod_epicloot_me_adrenalinecharge_desc": "When your adrenaline fills, reduce the remaining cooldown on your Forsaken Power by {0}%.", + + "mod_epicloot_me_staminaonkill_display": "Restore {0}% Stamina on Kill", + "mod_epicloot_me_staminaonkill_desc": "Restore {0}% of max stamina each time you kill an enemy.", + "mod_epicloot_me_heartyeitr_display": "+{0}% of Max Health as Max Eitr", + "mod_epicloot_me_heartyeitr_desc": "Your heartiness feeds your magic: increases maximum Eitr by {0}% of your maximum Health.", + "mod_epicloot_me_energeticeitr_display": "+{0}% of Max Stamina as Max Eitr", + "mod_epicloot_me_energeticeitr_desc": "Your vigor feeds your magic: increases maximum Eitr by {0}% of your maximum Stamina.", + "mod_epicloot_me_burningspeed_display": "+{0}% Move Speed While Burning", + "mod_epicloot_me_burningspeed_desc": "Move {0}% faster while you are on fire.", + "mod_epicloot_me_perfectdodgegivesspeed_display": "Perfect Dodge: Dodge Agility (+{0}% Speed, 1s)", + "mod_epicloot_me_perfectdodgegivesspeed_desc": "A perfect dodge grants Dodge Agility: +{0}% movement speed for 1 second.", + "mod_epicloot_me_nightcarryweight_display": "+{0}% Carry Weight at Night", + "mod_epicloot_me_nightcarryweight_desc": "Increases max carry weight by {0}% during the night.", + "mod_epicloot_me_daysailingspeed_display": "+{0}% Sailing Speed by Day", + "mod_epicloot_me_daysailingspeed_desc": "Increases your ship's sailing speed by {0}% during the day.", + "mod_epicloot_me_armorfrommovementpenalty_display": "+Up to {0}% Armor from Heavy Gear", + "mod_epicloot_me_armorfrommovementpenalty_desc": "Armor scaling with your gear's speed penalty, up to +{0}%.", + "mod_epicloot_me_anchoredblock_display": "+{0} Base Block per 1% Equipment Speed Penalty", + "mod_epicloot_me_anchoredblock_desc": "Adds {0} base block for every 1% of movement speed penalty from your equipment. The bonus is not affected by explicit sources of movement speed such as potions, status effects, and magic effects.", + "mod_epicloot_me_reduceeitrcost_display": "-{0}% Eitr Costs", + "mod_epicloot_me_reduceeitrcost_desc": "Reduces all Eitr costs by {0}%.", + "mod_epicloot_me_reducefishingstaminacost_display": "-{0}% Fishing Stamina", + "mod_epicloot_me_reducefishingstaminacost_desc": "Reduces the stamina cost of fishing by {0}%.", + "mod_epicloot_me_reducearmorincreasedamage_display": "-{0}% Armor, +{0}% Damage", + "mod_epicloot_me_reducearmorincreasedamage_desc": "Reduces your armor by {0}% but increases weapon damage by {0}%.", + "mod_epicloot_me_blooddrinker_display": "-{1}% Max Health, +{0}% Lifesteal", + "mod_epicloot_me_blooddrinker_desc": "Reduces your maximum health by {1}% (at least 10), healing you for {0}% of the damage you deal.", + "mod_epicloot_me_poisontotruedamage_display": "{0}% Poison done as target's weakest damage type", + "mod_epicloot_me_poisontotruedamage_desc": "Converts {0}% of your poison damage into the target's least-resisted damage type.", + "mod_epicloot_me_icyweight_display": "+Up to {0}% Frost Damage from Heavy Gear", + "mod_epicloot_me_icyweight_desc": "Adds frost damage scaling with your gear's speed penalty, up to +{0}% of weapon damage.", + "mod_epicloot_me_gaineitrwhensacrificinghealth_display": "Gain Eitr: {0}% of Health Spent", + "mod_epicloot_me_gaineitrwhensacrificinghealth_desc": "Gain Eitr equal to {0}% of the health you spend on blood-magic costs.", + "mod_epicloot_me_luckycraft_display": "{0}% Chance to Save Each Material", + "mod_epicloot_me_luckycraft_desc": "When crafting, each required material has a {0}% chance not to be consumed.", + "mod_epicloot_me_strikecauseslightning_display": "{0}% Chance to Call Lightning", + "mod_epicloot_me_strikecauseslightning_desc": "Your hits have a {0}% chance to call down a lightning strike on the target.", + "mod_epicloot_me_conduit_display": "Conduit: Restore {0} Eitr per {1} Lightning Damage Dealt", + "mod_epicloot_me_conduit_desc": "Restore {0} eitr for every {1} lightning damage you deal.", + "mod_epicloot_me_restinghealthregen_display": "+{0}% Health Regen While Rested", + "mod_epicloot_me_restinghealthregen_desc": "Increases health regeneration by {0}% while you are Rested.", + "mod_epicloot_me_travellight_display": "+{0}% Move Speed, -{1} Carry Weight", + "mod_epicloot_me_travellight_desc": "Travel light: move {0}% faster, but your max carry weight is reduced by {1}.", + "mod_epicloot_me_batteringram_display": "Battering Ram: Blunt from Weight", + "mod_epicloot_me_batteringram_desc": "Running into enemies deals blunt damage based on your speed and carried weight (scaled by {0}).", + "mod_epicloot_me_gainmaxcarryweightfromrested_display": "+{0} Carry Weight per Rested Level", + "mod_epicloot_me_gainmaxcarryweightfromrested_desc": "While Rested, increases max carry weight by {0} for each level of your Rested comfort.", + + "mod_epicloot_me_perfectdodge_display": "Perfect Dodge: +{0}% Stamina Regen per Stack (max {1})", + "mod_epicloot_me_perfectdodge_desc": "A perfect dodge grants Dodge Momentum: +{0}% stamina regeneration for 10 seconds, stacking up to {1} times. Each perfect dodge refreshes the duration.", + "mod_epicloot_me_perfectdodgegiveshealth_display": "Perfect Dodge Restores {0}% Health", + "mod_epicloot_me_perfectdodgegiveshealth_desc": "Restore {0}% of max health on a perfect dodge.", + "mod_epicloot_me_perfectdodgegivesstamina_display": "Perfect Dodge Restores {0}% Stamina", + "mod_epicloot_me_perfectdodgegivesstamina_desc": "Restore {0}% of max stamina on a perfect dodge.", + "mod_epicloot_me_perfectdodgegiveseitr_display": "Perfect Dodge Restores {0}% Eitr", + "mod_epicloot_me_perfectdodgegiveseitr_desc": "Restore {0}% of max eitr on a perfect dodge.", + "mod_epicloot_me_decreasedodgecost_display": "-{0}% Dodge Stamina Cost", + "mod_epicloot_me_decreasedodgecost_desc": "Reduce the stamina cost of dodge rolls by {0}%.", + + "mod_epicloot_me_increasedamageduringnighttime_display": "+{0}% Damage at Night", + "mod_epicloot_me_increasedamageduringnighttime_desc": "+{0}% weapon damage while it is night.", + "mod_epicloot_me_nightstaminaregenincrease_display": "+{0}% Stamina Regen at Night", + "mod_epicloot_me_nightstaminaregenincrease_desc": "+{0}% stamina regeneration while it is night.", + "mod_epicloot_me_damagereductionatnight_display": "-{0}% Damage Taken at Night", + "mod_epicloot_me_damagereductionatnight_desc": "Reduce incoming damage by {0}% while it is night.", + "mod_epicloot_me_increasedamageduringdaytime_display": "+{0}% Damage in Daylight", + "mod_epicloot_me_increasedamageduringdaytime_desc": "+{0}% weapon damage while it is day.", + "mod_epicloot_me_daydiscovery_display": "+{0}% Exploration Radius in Daylight", + "mod_epicloot_me_daydiscovery_desc": "Widen your map-discovery radius by {0}% while it is day.", + "mod_epicloot_me_dayarmor_display": "+{0}% Armor in Daylight", + "mod_epicloot_me_dayarmor_desc": "+{0}% armor while it is day.", + "mod_epicloot_me_daystaminaregen_display": "+{0}% Stamina Regen in Daylight", + "mod_epicloot_me_daystaminaregen_desc": "+{0}% stamina regeneration while it is day.", + "mod_epicloot_me_dayhealthregen_display": "+{0}% Health Regen in Daylight", + "mod_epicloot_me_dayhealthregen_desc": "+{0}% health regeneration while it is day.", + + "mod_epicloot_me_percenthealth_display": "+{0}% Max Health", + "mod_epicloot_me_percenthealth_desc": "Increase max health by {0}%.", + "mod_epicloot_me_percentstamina_display": "+{0}% Max Stamina", + "mod_epicloot_me_percentstamina_desc": "Increase max stamina by {0}%.", + "mod_epicloot_me_percenteitr_display": "+{0}% Max Eitr", + "mod_epicloot_me_percenteitr_desc": "Increase max eitr by {0}%.", + "mod_epicloot_me_healthgainperxdamagedone_display": "Heal {0} per {1} Damage Dealt", + "mod_epicloot_me_healthgainperxdamagedone_desc": "Heal {0} health for every {1} damage you deal with this weapon.", + "mod_epicloot_me_healthoneitruse_display": "Heal {0} per {1} Eitr Spent", + "mod_epicloot_me_healthoneitruse_desc": "Heal {0} health for every {1} eitr you spend with this weapon.", + "mod_epicloot_me_lifegainonhit_display": "Heal {0:0.#} on Hit", + "mod_epicloot_me_lifegainonhit_desc": "Heal {0:0.#} health each time you hit an enemy with this weapon.", + + "mod_epicloot_me_phystofire_display": "Convert {0}% Incoming Physical to Fire", + "mod_epicloot_me_phystofire_desc": "Move {0}% of incoming physical damage to fire before resistances.", + "mod_epicloot_me_phystofrost_display": "Convert {0}% Incoming Physical to Frost", + "mod_epicloot_me_phystofrost_desc": "Move {0}% of incoming physical damage to frost before resistances.", + "mod_epicloot_me_phystopoison_display": "Convert {0}% Incoming Physical to Poison", + "mod_epicloot_me_phystopoison_desc": "Move {0}% of incoming physical damage to poison before resistances.", + "mod_epicloot_me_phystolightning_display": "Convert {0}% Incoming Physical to Lightning", + "mod_epicloot_me_phystolightning_desc": "Move {0}% of incoming physical damage to lightning before resistances.", + + "mod_epicloot_me_phystofireonblock_display": "Convert {0}% Incoming Physical to Fire on block", + "mod_epicloot_me_phystofireonblock_desc": "Move {0}% of incoming physical damage to fire before resistances on block", + "mod_epicloot_me_phystofrostonblock_display": "Convert {0}% Incoming Physical to Frost on block", + "mod_epicloot_me_phystofrostonblock_desc": "Move {0}% of incoming physical damage to frost before resistances on block.", + "mod_epicloot_me_phystopoisononblock_display": "Convert {0}% Incoming Physical to Poison on block", + "mod_epicloot_me_phystopoisononblock_desc": "Move {0}% of incoming physical damage to poison before resistances on block.", + "mod_epicloot_me_phystolightningonblock_display": "Convert {0}% Incoming Physical to Lightning on block", + "mod_epicloot_me_phystolightningonblock_desc": "Move {0}% of incoming physical damage to lightning before resistances on block.", + + "mod_epicloot_me_lifegainonblock_display": "Heal {0} hp on block", + "mod_epicloot_me_lifegainonblock_desc": "Heal {0} health on block", + "mod_epicloot_me_staminagainonblock_display": "Gain {0} stamina on block", + "mod_epicloot_me_staminagainonblock_desc": "Gain {0} stamina on block.", + "mod_epicloot_me_eitrgainonblock_display": "Gain {0} eitr on block", + "mod_epicloot_me_eitrgainonblock_desc": "Gain {0} eitr on block.", + + "mod_epicloot_me_staggeronblock_display": "{0}% chance to stagger enemy on block", + "mod_epicloot_me_staggeronblock_desc": "{0}% chance to stagger enemy on block", + + "mod_epicloot_me_dayblocker_display": "Gain {0}% Block XP during Daytime", + "mod_epicloot_me_dayblocker_desc": "Gain {0}% increased block experience during Daytime.", + "mod_epicloot_me_nightblocker_display": "Gain {0}% Block XP during Nighttime", + "mod_epicloot_me_nightblocker_desc": "Gain {0}% increased block experience during Nighttime.", + + "mod_epicloot_me_convertphysicaldamagetolightning_display": "Convert {0}% Weapon Physical to Lightning", + "mod_epicloot_me_convertphysicaldamagetolightning_desc": "Move {0}% of this weapon's physical damage to lightning.", + "mod_epicloot_me_eitrimbueattack_display": "Eitr Imbue: +{0}% Physical as Spirit", + "mod_epicloot_me_eitrimbueattack_desc": "Spend eitr to add spirit damage equal to {0}% of the hit's physical damage.", + + "mod_epicloot_me_staminareturnfromeitr_display": "Refund {0}% of Eitr Spent as Stamina", + "mod_epicloot_me_staminareturnfromeitr_desc": "Restore stamina equal to {0}% of the eitr you spend.", + "mod_epicloot_me_converteitrcosttostaminacost_display": "Pay {0}% of Eitr Costs from Stamina", + "mod_epicloot_me_converteitrcosttostaminacost_desc": "Pay {0}% of every eitr cost from stamina instead.", + "mod_epicloot_me_consumeeitrfirstforbloodcosts_display": "Pay {0}% of Blood Costs from Eitr", + "mod_epicloot_me_consumeeitrfirstforbloodcosts_desc": "Pay {0}% of blood-magic health costs from eitr first.", + "mod_epicloot_me_runningonempty_display": "Second Wind: {0}% of Max Health", + "mod_epicloot_me_runningonempty_desc": "When stamina runs out, burn up to {0}% of your max health for that much stamina. 30 second cooldown.", + "mod_epicloot_me_everyxpointsofeitrincreasesstamina_display": "+Max Stamina from {0}% of Max Eitr", + "mod_epicloot_me_everyxpointsofeitrincreasesstamina_desc": "Increase max stamina by {0}% of your max eitr.", + "mod_epicloot_me_eitrshield_display": "Absorb {0}% of Hits with Eitr", + "mod_epicloot_me_eitrshield_desc": "Spend eitr to absorb {0}% of an incoming hit.", + "mod_epicloot_me_coinplated_display": "Coinplated: Absorb Hits with Coins ({0} per Coin)", + "mod_epicloot_me_coinplated_desc": "Commit up to {1}% of your coins to each incoming hit, absorbing {0} damage per coin spent. Only the coins actually needed are spent, so small hits cost little.", + + "mod_epicloot_me_damagebonusfromplayerweight_display": "+Up to {0}% Damage by Pack Weight", + "mod_epicloot_me_damagebonusfromplayerweight_desc": "Weapon damage scaling with how loaded your pack is, up to +{0}%.", + "mod_epicloot_me_staminaregenbonusfromplayerweight_display": "+Up to {0}% Stamina Regen by Pack Weight", + "mod_epicloot_me_staminaregenbonusfromplayerweight_desc": "Stamina regen scaling with how loaded your pack is, up to +{0}%.", + "mod_epicloot_me_gainmaxstaminabasedonplayermaxhealth_display": "+Max Stamina from {0}% of Max Health", + "mod_epicloot_me_gainmaxstaminabasedonplayermaxhealth_desc": "Increase max stamina by {0}% of your max health.", + "mod_epicloot_me_damageincreasefrommovementpenalty_display": "+Up to {0}% Damage from Heavy Gear", + "mod_epicloot_me_damageincreasefrommovementpenalty_desc": "Weapon damage scaling with your gear's speed penalty, up to +{0}%.", + "mod_epicloot_me_increasexpgainfrommovementpenalty_display": "+Up to {0}% Skill XP from Heavy Gear", + "mod_epicloot_me_increasexpgainfrommovementpenalty_desc": "Skill XP scaling with your gear's speed penalty, up to +{0}%.", + "mod_epicloot_me_carryweightformovementpenalty_display": "+Up to {0}% Carry Weight from Heavy Gear", + "mod_epicloot_me_carryweightformovementpenalty_desc": "Max carry weight scaling with your gear's speed penalty, up to +{0}%.", + "mod_epicloot_me_staminaincreaseformovementpenalty_display": "+Up to {0}% Max Stamina from Heavy Gear", + "mod_epicloot_me_staminaincreaseformovementpenalty_desc": "Max stamina scaling with your gear's speed penalty, up to +{0}%.", + + "mod_epicloot_me_increaseharvestdamage_display": "+{0}% Harvest Damage", + "mod_epicloot_me_increaseharvestdamage_desc": "+{0}% chopping and mining damage.", + "mod_epicloot_me_increaseharvestxpgain_display": "+{0}% Gathering Skill XP", + "mod_epicloot_me_increaseharvestxpgain_desc": "+{0}% XP for Woodcutting and Pickaxes.", + "mod_epicloot_me_bountifulharvest_display": "Bountiful Harvest: {0}% Bonus Yield", + "mod_epicloot_me_bountifulharvest_desc": "{0}% chance to gather a bonus item when harvesting.", + "mod_epicloot_me_wager_display": "Wager: Stake {0} Coins for +{1} Damage, Refunded on a Kill", + "mod_epicloot_me_wager_desc": "Every hit stakes {0} coins to add {1} flat damage. Kill the target and the stake is refunded in full; leave it alive and the coins are lost. Does nothing if you cannot cover the stake.", + "mod_epicloot_me_mercenary_display": "Mercenary: Spend {1} + {2}% Coins for +{3}% Damage, +{0}% per 1000 Spent", + "mod_epicloot_me_mercenary_desc": "Each hit spends {1} coins plus {2}% of your purse, dealing +{3}% damage plus a further +{0}% for every 1000 coins spent. Bonus damage past +{4}% suffers sharply diminishing returns, approaching a ceiling of +{5}%. Does nothing if you cannot pay.", + "mod_epicloot_me_luckwhilefishing_display": "Lucky Fishing: {0}% Bonus Catch & Treasure", + "mod_epicloot_me_luckwhilefishing_desc": "While fishing, {0}% chance to reel in an extra fish ({1}% of those land a triple catch), and a separate {0}% chance to hook bonus treasure. Higher values reach richer finds and stop turning up junk.", + "mod_epicloot_me_chancedoubledamage_display": "{0}% Chance to Double Damage", + "mod_epicloot_me_chancedoubledamage_desc": "{0}% chance for a hit to deal double damage.", + "mod_epicloot_me_inspiration_display": "Inspiration: {1}% Chance of +{0} Skill XP", + "mod_epicloot_me_inspiration_desc": "Every scrap of experience you earn carries a {1}% chance to inspire you, granting {0} bonus experience to a random skill you have already trained past level 2. Your weakest skills are far likelier to be chosen, and a single flash of inspiration can carry a skill through more than one level.", + "mod_epicloot_me_luckyblock_display": "Lucky Block: {0}% Chance to Stagger", + "mod_epicloot_me_luckyblock_desc": "Each block has a {0}% chance to stagger your attacker outright, as though you had parried it. Enemies that cannot be staggered by a block are unaffected.", + "mod_epicloot_me_luckyloot_display": "Lucky Loot: {0}% Chance of {1}x Drops", + "mod_epicloot_me_luckyloot_desc": "Each creature slain nearby has a {0}% chance to drop {1}x its usual loot and to roll {2}-{3} extra times on its magic item table. Trophies and one-per-player rewards are never multiplied.", + "mod_epicloot_msg_inspiration": "Inspiration!", + "mod_epicloot_me_sailingspeed_display": "+{0}% Sailing Speed", + "mod_epicloot_me_sailingspeed_desc": "+{0}% ship sail speed while aboard.", + "mod_epicloot_me_potionefficacy_display": "+{0}% Potion Duration", + "mod_epicloot_me_potionefficacy_desc": "Consumed potions and meads last {0}% longer.", + "mod_epicloot_me_increasemeleeskills_display": "+{0} Melee Skills", + "mod_epicloot_me_increasemeleeskills_desc": "+{0} to all melee weapon skills.", + "mod_epicloot_me_increaserangedskills_display": "+{0} Ranged Skills", + "mod_epicloot_me_increaserangedskills_desc": "+{0} to all ranged weapon skills.", + "mod_epicloot_me_blockasdodgeasblock_display": "Adds +{1} block skill and +{2} dodge skill", + "mod_epicloot_me_blockasdodgeasblock_desc": "Adds +{0}% of blocking skill as dodge skill and +{0}% of dodge skill as blocking skill. This bonus is rounded down to whole values", + + "mod_epicloot_me_burdenedblock_display": "Adds +{0} base block per 50 Pack Weight over 300", + "mod_epicloot_me_burdenedblock_desc": "Adds +{0} base block per 50 Pack Weight over 300", + + "mod_epicloot_me_warding_display": "Block: Absorb Physical with Stamina, up to {0}% of max", + "mod_epicloot_me_warding_desc": "Allows for {0}% of maximum stamina to be used on block to mitigate any physical (pierce, blunt, slash) damage taken from blocked attacks. This mitigation is calculated after the block and before resistances and armor calculations take place. Consumes stamina equal to damage mitigated", + + "mod_epicloot_me_elementalwarding_display": "Block: Absorb Elemental with Eitr, up to {0}% of max", + "mod_epicloot_me_elementalwarding_desc": "Allows for {0}% of maximum eitr to be used on block to mitigate any elemental (fire, frost, lightning) damage taken from blocked attacks. This mitigation is calculated after the block and before resistances and armor calculations take place. Consumes eitr equal to damage mitigated", + + "mod_epicloot_me_bloodstaggerblock_display": "Consumes 5% of max HP to block. Reduce stagger damage from blocked hits by {0}%", + "mod_epicloot_me_bloodstaggerblock_desc": "Consumes 5% of max HP to block. Reduce stagger damage from blocked hits by {0}%. This effect is active for the entire duration of any block", + + "mod_epicloot_me_bloodbaseblock_display": "Raising your guard costs 5% of max HP. Adds {0} to your base block", + "mod_epicloot_me_bloodbaseblock_desc": "Damages self for 5% of max HP as true damage each time you raise your guard. This is charged once per block, not per blocked hit. Adds {0} to your base block.", + + "mod_epicloot_me_blockaswoodcuttingandpickaxes_display": "Adds +{1} block skill", + "mod_epicloot_me_blockaswoodcuttingandpickaxes_desc": "Adds +{0}% of woodcutting and pickaxes skills after all modifiers as block skill. This bonus is rounded down to whole values", + + "mod_epicloot_me_increaseallpoisondamagedone_display": "+{0}% Poison Damage", + "mod_epicloot_me_increaseallpoisondamagedone_desc": "+{0}% to all poison damage you deal.", + "mod_epicloot_me_killsreducenextbloodcost_display": "Kills Cut Next Blood Cost by {0}%", + "mod_epicloot_me_killsreducenextbloodcost_desc": "Each kill banks a {0}% reduction to your next blood-magic cost.", + "mod_epicloot_me_bloodmagiclevelincreaseshealthregen_display": "+Up to {0}% Health Regen by Blood Magic", + "mod_epicloot_me_bloodmagiclevelincreaseshealthregen_desc": "Health regen scaling with your Blood Magic skill, up to +{0}%.", + "mod_epicloot_me_chancetocritonhit_display": "{0}% Chance to Crit for {1}x Damage", + "mod_epicloot_me_chancetocritonhit_desc": "{0}% chance for a hit to critically strike for double damage.", + "mod_epicloot_me_bloodrage_display": "Bloodrage: +{0}% Damage per Stack (max {1})", + "mod_epicloot_me_bloodrage_desc": "Taking damage sends you into a rage: +{0}% damage on every attack per stack, stacking up to {1} times over 10 seconds. Each hit you take refreshes the duration.", + + "mod_epicloot_me_trailblazer_display": "Burning Trail: {0} Fire per Tick", + "mod_epicloot_me_trailblazer_desc": "Leave a burning trail while running that deals {0} fire damage per tick.", + "mod_epicloot_me_kindling_display": "Kindling: Restore {0} Stamina per {1} Fire Damage Taken", + "mod_epicloot_me_kindling_desc": "Restore {0} stamina for every {1} fire damage you take.", + "mod_epicloot_me_reducefalldamage_display": "-{0} Fall Damage", + "mod_epicloot_me_reducefalldamage_desc": "Reduce fall damage taken by a flat {0}.", + "mod_epicloot_me_rollcleanse_display": "Dodge Cleanses {0}s of DoTs", + "mod_epicloot_me_rollcleanse_desc": "Each dodge roll removes {0} seconds from your poison and burning effects.", + "mod_epicloot_me_stormrider_display": "+{0}% Movement Speed in Storms", + "mod_epicloot_me_stormrider_desc": "+{0}% movement speed while a storm rages.", + + "mod_epicloot_me_shockingcharge_display": "Shocking Charge", + "mod_epicloot_me_shockingcharge_desc": "Your combat hits build charges. At max charge, the next hit unleashes a forward lightning shockwave that deals a portion of your previous hits' damage.", + "mod_epicloot_me_forestsaid_display": "Forest's Aid", + "mod_epicloot_me_forestsaid_desc": "When you are struck, ensnaring roots erupt and immobilize nearby enemies.", + "mod_epicloot_me_corpserot_display": "Corpse Rot", + "mod_epicloot_me_corpserot_desc": "Enemies you kill can burst in a cloud of venom, poisoning nearby foes.", + "mod_epicloot_me_icyretribution_display": "Icy Retribution", + "mod_epicloot_me_icyretribution_desc": "When you are struck, detonate a frost nova around you. Cooldown scales with rarity.", + "mod_epicloot_me_meteorsummoner_display": "Meteor Summoner: {0}x Hit Damage", + "mod_epicloot_me_meteorsummoner_desc": "Every {1} weapon hits, your next hit calls down a fiery meteor on the target that deals {0}x that hit's damage as fire.", + "mod_epicloot_me_everflow_display": "Queen's Everflow", + "mod_epicloot_me_everflow_desc": "Killing a creature grants a stacking buff that boosts your health, stamina and eitr regeneration.", + "mod_epicloot_me_necroticfire_display": "Necrotic Fire", + "mod_epicloot_me_necroticfire_desc": "Strips all physical damage from your weapon and adds that full amount as both fire and poison damage.", + + "mod_epicloot_circlet": "circlet", "mod_epicloot_belt": "belt", "mod_epicloot_remains": "remains", @@ -104,6 +518,8 @@ "mod_epicloot_enchantprogress": "Enchanting item...", "mod_epicloot_enchant_effect": "effect", "mod_epicloot_enchant_effects": "effects", + "mod_epicloot_enchant_socket": "shard slot", + "mod_epicloot_enchant_sockets": "shard slots", "mod_epicloot_runes": "Runes", "mod_epicloot_runemanagement": "Rune Manipulation", @@ -113,7 +529,10 @@ "mod_epicloot_rune_extract": "Extract Rune", "mod_epicloot_rune_imbue": "Imbue Rune", "mod_epicloot_rune_etch_warning": "Etching a rune allows it to hold a single enchantment. It can't be used for enchanting afterwards.", - "mod_epicloot_rune_extract_warning": "Extracting an enchantment destroys the item, but creates a rune with the selected enchantment.", + "mod_epicloot_rune_extract_warning_keep": "Extracting an enchantment creates a rune with the selected enchantment. The item is left untouched.", + "mod_epicloot_rune_extract_warning_reduce": "Extracting an enchantment removes it from the item and creates a rune with the selected enchantment.", + "mod_epicloot_rune_extract_warning_reduce_rarity": "Extracting an enchantment removes it from the item, reduces the item's rarity, and creates a rune with the selected enchantment.", + "mod_epicloot_rune_extract_warning_destroy": "Extracting an enchantment destroys the item, but creates a rune with the selected enchantment.", "mod_epicloot_rune_imbue_warning": "Imbuing a rune applies it to an item, overwriting a selected enchantment.", "mod_epicloot_rune_enchantment_selector": "Select Enchantment", "mod_epicloot_rune_enchantment_overwrite_target": "Select Enchantment to overwrite.", @@ -156,7 +575,7 @@ "mod_epicloot_bonus": "Bonus!", "mod_epicloot_featureinfo_none": "Select a feature from the list to the left to view upgrade options.", - "mod_epicloot_featureinfo_sacrifice": "Sacrifice magic items and trophies, destroying them to produce enchanting materials.\n\nWhen upgraded, gain a chance to recieve double the items normally gained from the sacrifice.", + "mod_epicloot_featureinfo_sacrifice": "Sacrifice magic items and trophies, destroying them to produce enchanting materials.\n\nWhen upgraded, gain a chance to receive double the items normally gained from the sacrifice.", "mod_epicloot_featureinfo_convertmaterials": "Upgrade enchanting materials to higher rarity, convert shards to enchanting materials of equal rarity, and salvage other materials from junk.\n\nWhen upgraded, improve the item ratio when upgrading runestones and other crafting materials.", "mod_epicloot_featureinfo_enchant": "Spend enchanting materials to add magic effects to non-magic gear. Requires runestones.\n\nWhen upgraded, gain an improved chance to roll more magic effects on an enchanted item.", "mod_epicloot_featureinfo_augment": "Spend enchanting materials to re-roll individual magic effects on magic gear.\n\nWhen upgraded, gain additional choices when augmenting, and have a reduced cost for multiple augments.", @@ -196,6 +615,20 @@ "mod_epicloot_item_andvaranaut_type": "dwarven ring", "mod_epicloot_se_paralyze": "Paralyze", + "mod_epicloot_se_shockingcharge": "Eikthyr's Charge", + "mod_epicloot_se_forestsaid": "Forest's Respite", + "mod_epicloot_se_corpserot": "Bonemass' Rot", + "mod_epicloot_se_runningonempty": "Second Wind", + "mod_epicloot_se_icyretribution": "Moder's Chill", + "mod_epicloot_se_meteorsummoner": "Yagluth's Wrath", + "mod_epicloot_se_queeneverflow": "Queen's Everflow", + "mod_epicloot_se_dodgeagility": "Dodge Agility", + "mod_epicloot_se_dodgeagility_speed": "Movement Speed", + "mod_epicloot_se_dodgemomentum": "Dodge Momentum", + "mod_epicloot_se_adrenalinesurge": "Adrenaline Surge", + "mod_epicloot_se_bloodrage": "Bloodrage", + "mod_epicloot_se_bloodrite": "Blood Rite", + "mod_epicloot_se_bloodrite_discount": "Next Blood Cost", "mod_epicloot_se_bulwark": "Bulwark", "mod_epicloot_se_bulwark_tooltip": "Heimdall's Legacy protects you from all damage!", "mod_epicloot_se_bulwark_startmessage": "Bulwark Activated", @@ -209,6 +642,8 @@ "mod_epicloot_itemtooltip_rarity": "Rarity", "mod_epicloot_itemtooltip_effects": "Effects", + "mod_epicloot_adventure_spawnrelocated": "Your map has been redrawn - the marked area was occupied.", + "mod_epicloot_bounties_claimedmsg": "Bounty Claimed!", "mod_epicloot_bounties_completemsg": "Bounty Vanquished!", "mod_epicloot_bounties_locatingmsg": "Locating Bounty Target...", @@ -801,47 +1236,47 @@ "mod_epicloot_me_addpoisonresistance_suffix1": "Poison Resistance", "mod_epicloot_me_addspiritresistance_prefix1": "Serene", "mod_epicloot_me_addspiritresistance_suffix1": "Spirit Resistance", - "mod_epicloot_me_addfireresistancepercentage_display": "Fire Damage -{0:0.#}%", + "mod_epicloot_me_addfireresistancepercentage_display": "Fire Resistance {0:0.#}%", "mod_epicloot_me_addfireresistancepercentage_desc": "The player reduces all fire damage taken by +X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addfireresistancepercentage_prefix1": "Extinguishing", "mod_epicloot_me_addfireresistancepercentage_suffix1": "Fire Resistance", - "mod_epicloot_me_addfrostresistancepercentage_display": "Frost Damage -{0:0.#}%", + "mod_epicloot_me_addfrostresistancepercentage_display": "Frost Resistance {0:0.#}%", "mod_epicloot_me_addfrostresistancepercentage_desc": "The player reduces all frost damage taken by +X%. This effect does not provide protection from environmental effects. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addfrostresistancepercentage_prefix1": "Insulated", "mod_epicloot_me_addfrostresistancepercentage_suffix1": "Frost Resistance", - "mod_epicloot_me_addlightningresistancepercentage_display": "Lightning Damage -{0:0.#}%", + "mod_epicloot_me_addlightningresistancepercentage_display": "Lightning Resistance {0:0.#}%", "mod_epicloot_me_addlightningresistancepercentage_desc": "The player reduces all lightning damage taken by +X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addlightningresistancepercentage_prefix1": "Grounded", "mod_epicloot_me_addlightningresistancepercentage_suffix1": "Lightning Resistance", - "mod_epicloot_me_addpoisonresistancepercentage_display": "Poison Damage -{0:0.#}%", + "mod_epicloot_me_addpoisonresistancepercentage_display": "Poison Resistance {0:0.#}%", "mod_epicloot_me_addpoisonresistancepercentage_desc": "The player reduces all poison damage taken by +X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addpoisonresistancepercentage_prefix1": "Curing", "mod_epicloot_me_addpoisonresistancepercentage_suffix1": "Poison Resistance", - "mod_epicloot_me_addspiritresistancepercentage_display": "Spirit Damage -{0:0.#}%", + "mod_epicloot_me_addspiritresistancepercentage_display": "Spirit Resistance {0:0.#}%", "mod_epicloot_me_addspiritresistancepercentage_desc": "The player reduces all spirit damage taken by +X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addspiritresistancepercentage_prefix1": "Serene", "mod_epicloot_me_addspiritresistancepercentage_suffix1": "Spirit Resistance", - "mod_epicloot_me_addelementalresistancepercentage_display": "Elemental Damage -{0:0.#}%", + "mod_epicloot_me_addelementalresistancepercentage_display": "Elemental Resistance {0:0.#}%", "mod_epicloot_me_addelementalresistancepercentage_desc": "The player reduces all elemental damage (fire, frost, lightning, poison) taken by X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addelementalresistancepercentage_prefix1": "Warding", "mod_epicloot_me_addelementalresistancepercentage_suffix1": "Elemental Resistance", - "mod_epicloot_me_addbluntresistancepercentage_display": "Blunt Damage -{0:0.#}%", + "mod_epicloot_me_addbluntresistancepercentage_display": "Blunt Resistance {0:0.#}%", "mod_epicloot_me_addbluntresistancepercentage_desc": "The player reduces all blunt damage taken by X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addbluntresistancepercentage_prefix1": "Defensive", "mod_epicloot_me_addbluntresistancepercentage_suffix1": "Blunt Resistance", - "mod_epicloot_me_addslashingresistancepercentage_display": "Slash Damage -{0:0.#}%", + "mod_epicloot_me_addslashingresistancepercentage_display": "Slashing Resistance {0:0.#}%", "mod_epicloot_me_addslashingresistancepercentage_desc": "The player reduces all slash damage taken by X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addslashingresistancepercentage_prefix1": "Impervious", "mod_epicloot_me_addslashingresistancepercentage_suffix1": "Slashing Resistance", - "mod_epicloot_me_addpiercingresistancepercentage_display": "Pierce Damage -{0:0.#}%", + "mod_epicloot_me_addpiercingresistancepercentage_display": "Piercing Resistance {0:0.#}%", "mod_epicloot_me_addpiercingresistancepercentage_desc": "The player reduces all pierce damage taken by X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addpiercingresistancepercentage_prefix1": "Impenetrable", "mod_epicloot_me_addpiercingresistancepercentage_suffix1": "Piercing Resistance", - "mod_epicloot_me_addchoppingresistancepercentage_display": "Chopping Damage -{0:0.#}%", + "mod_epicloot_me_addchoppingresistancepercentage_display": "Chopping Resistance {0:0.#}%", "mod_epicloot_me_addchoppingresistancepercentage_desc": "The player reduces all chopping damage taken by X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addchoppingresistancepercentage_prefix1": "Unchoppable", "mod_epicloot_me_addchoppingresistancepercentage_suffix1": "Chopping Resistance", - "mod_epicloot_me_addphysicalresistancepercentage_display": "Physical Damage -{0:0.#}%", + "mod_epicloot_me_addphysicalresistancepercentage_display": "Physical Resistance {0:0.#}%", "mod_epicloot_me_addphysicalresistancepercentage_desc": "The player reduces all physical damage (blunt, slash, pierce) taken by X%. This effect stacks additively with other damage reduction effects equipped by the player.", "mod_epicloot_me_addphysicalresistancepercentage_prefix1": "Toughening", "mod_epicloot_me_addphysicalresistancepercentage_suffix1": "Physical Resistance", @@ -1159,7 +1594,7 @@ "mod_epicloot_me_spellsword_desc": "Converts a 50% of usage cost to eitr, increases damage by +X%.", "mod_epicloot_me_spellsword_prefix1": "Spellblade", "mod_epicloot_me_spellsword_suffix1": "Spellblade", - "mod_epicloot_me_bulkup_display": "Bulk +{0}", + "mod_epicloot_me_bulkup_display": "Bulk: +{0}% Max HP, -{1}% Regen", "mod_epicloot_me_bulkup_desc": "Converts a portion of your regeneration into maximum health.", "mod_epicloot_me_bulkup_prefix1": "Bulky", "mod_epicloot_me_bulkup_suffix1": "of Bulking", @@ -1472,13 +1907,21 @@ "mod_epicloot_Epic": "Epic", "mod_epicloot_Legendary": "Legendary", "mod_epicloot_Mythic": "Mythic", - "mod_epicloot_magic": "Magic", - "mod_epicloot_rare": "Rare", - "mod_epicloot_epic": "Epic", - "mod_epicloot_legendary": "Legendary", - "mod_epicloot_mythic": "Mythic", "mod_epicloot_search_placeholder": "Search...", "mod_epicloot_no_active_adventures": "No active adventures", "mod_epicloot_set_bonuses": "Set Bonuses", - "mod_epicloot_legendary_sets": "Legendary Sets" + "mod_epicloot_legendary_sets": "Legendary Sets", + "mod_epicloot_enchantments": "Enchantments", + "mod_epicloot_output": "Output", + "mod_epicloot_history": "History", + "mod_epicloot_requirements": "Requirements", + "mod_epicloot_info": "Info", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Increment", + "mod_epicloot_minmax": "Min Max", + "mod_epicloot_successchance": "Success Chance", + "mod_epicloot_criticalsuccess": "Critical Success", + "mod_epicloot_criticalfail": "Critical Fail", + "mod_epicloot_temper": "Temper" } \ No newline at end of file diff --git a/EpicLoot/localizations/Finnish.json b/EpicLoot/localizations/Finnish.json index a0d7fb2d2..e0800442b 100644 --- a/EpicLoot/localizations/Finnish.json +++ b/EpicLoot/localizations/Finnish.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Haku...", "mod_epicloot_no_active_adventures": "Ei aktiivisia seikkailuja", "mod_epicloot_set_bonuses": "Settiponukset", - "mod_epicloot_legendary_sets": "Legendaariset setit" + "mod_epicloot_legendary_sets": "Legendaariset setit", + "mod_epicloot_enchantments": "Lumoukset", + "mod_epicloot_output": "Tulos", + "mod_epicloot_history": "Historia", + "mod_epicloot_requirements": "Vaatimukset", + "mod_epicloot_info": "Tiedot", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Lisäys", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Onnistumismahdollisuus", + "mod_epicloot_criticalsuccess": "Kriittinen onnistuminen", + "mod_epicloot_criticalfail": "Kriittinen epäonnistuminen", + "mod_epicloot_temper": "Karkaisu" } \ No newline at end of file diff --git a/EpicLoot/localizations/French.json b/EpicLoot/localizations/French.json index 2e43c2604..dfba63ba2 100644 --- a/EpicLoot/localizations/French.json +++ b/EpicLoot/localizations/French.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Rechercher...", "mod_epicloot_no_active_adventures": "Aucune aventure en cours", "mod_epicloot_set_bonuses": "Bonus de set", - "mod_epicloot_legendary_sets": "Ensembles légendaires" + "mod_epicloot_legendary_sets": "Ensembles légendaires", + "mod_epicloot_enchantments": "Enchantements", + "mod_epicloot_output": "Résultat", + "mod_epicloot_history": "Historique", + "mod_epicloot_requirements": "Prérequis", + "mod_epicloot_info": "Info", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Incrément", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Chance de réussite", + "mod_epicloot_criticalsuccess": "Réussite critique", + "mod_epicloot_criticalfail": "Échec critique", + "mod_epicloot_temper": "Trempe" } \ No newline at end of file diff --git a/EpicLoot/localizations/Georgian.json b/EpicLoot/localizations/Georgian.json index 07fa327ee..3e36a2b02 100644 --- a/EpicLoot/localizations/Georgian.json +++ b/EpicLoot/localizations/Georgian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "ძიება...", "mod_epicloot_no_active_adventures": "მოქმედი თავგადასავალი არ არის", "mod_epicloot_set_bonuses": "ჯგუფური ბონუსები", - "mod_epicloot_legendary_sets": "ლეგენდარული სეტები" + "mod_epicloot_legendary_sets": "ლეგენდარული სეტები", + "mod_epicloot_enchantments": "მოჯადოებები", + "mod_epicloot_output": "შედეგი", + "mod_epicloot_history": "ისტორია", + "mod_epicloot_requirements": "მოთხოვნები", + "mod_epicloot_info": "ინფორმაცია", + "mod_epicloot_min": "მინ", + "mod_epicloot_max": "მაქს", + "mod_epicloot_increment": "გაზრდა", + "mod_epicloot_minmax": "მინ/მაქს", + "mod_epicloot_successchance": "წარმატების შანსი", + "mod_epicloot_criticalsuccess": "კრიტიკული წარმატება", + "mod_epicloot_criticalfail": "კრიტიკული წარუმატებლობა", + "mod_epicloot_temper": "გამკვრივება" } \ No newline at end of file diff --git a/EpicLoot/localizations/German.json b/EpicLoot/localizations/German.json index cd19b7945..a99ed6afb 100644 --- a/EpicLoot/localizations/German.json +++ b/EpicLoot/localizations/German.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Suche...", "mod_epicloot_no_active_adventures": "Keine aktiven Abenteuer", "mod_epicloot_set_bonuses": "Set-Boni", - "mod_epicloot_legendary_sets": "Legendäre Sets" + "mod_epicloot_legendary_sets": "Legendäre Sets", + "mod_epicloot_enchantments": "Verzauberungen", + "mod_epicloot_output": "Ergebnis", + "mod_epicloot_history": "Verlauf", + "mod_epicloot_requirements": "Voraussetzungen", + "mod_epicloot_info": "Informationen", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Erhöhung", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Erfolgschance", + "mod_epicloot_criticalsuccess": "Kritischer Erfolg", + "mod_epicloot_criticalfail": "Kritischer Fehlschlag", + "mod_epicloot_temper": "Härten" } \ No newline at end of file diff --git a/EpicLoot/localizations/Greek.json b/EpicLoot/localizations/Greek.json index ec6cfcc26..ca1baaed7 100644 --- a/EpicLoot/localizations/Greek.json +++ b/EpicLoot/localizations/Greek.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Αναζήτηση...", "mod_epicloot_no_active_adventures": "Δεν υπάρχουν ενεργές περιπέτειες", "mod_epicloot_set_bonuses": "Μπόνους σετ", - "mod_epicloot_legendary_sets": "Θρυλικά Σετ" + "mod_epicloot_legendary_sets": "Θρυλικά Σετ", + "mod_epicloot_enchantments": "Μαγείες", + "mod_epicloot_output": "Αποτέλεσμα", + "mod_epicloot_history": "Ιστορικό", + "mod_epicloot_requirements": "Απαιτήσεις", + "mod_epicloot_info": "Πληροφορίες", + "mod_epicloot_min": "Ελάχ", + "mod_epicloot_max": "Μέγ", + "mod_epicloot_increment": "Αύξηση", + "mod_epicloot_minmax": "Ελάχ/Μέγ", + "mod_epicloot_successchance": "Πιθανότητα επιτυχίας", + "mod_epicloot_criticalsuccess": "Κρίσιμη επιτυχία", + "mod_epicloot_criticalfail": "Κρίσιμη αποτυχία", + "mod_epicloot_temper": "Σκλήρυνση" } \ No newline at end of file diff --git a/EpicLoot/localizations/Hindi.json b/EpicLoot/localizations/Hindi.json index fef68e64f..2c9facc4e 100644 --- a/EpicLoot/localizations/Hindi.json +++ b/EpicLoot/localizations/Hindi.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "खोजें...", "mod_epicloot_no_active_adventures": "कोई सक्रिय रोमांच नहीं", "mod_epicloot_set_bonuses": "सेट बोनस", - "mod_epicloot_legendary_sets": "पौराणिक सेट" + "mod_epicloot_legendary_sets": "पौराणिक सेट", + "mod_epicloot_enchantments": "मंत्रमुग्धता", + "mod_epicloot_output": "परिणाम", + "mod_epicloot_history": "इतिहास", + "mod_epicloot_requirements": "आवश्यकताएँ", + "mod_epicloot_info": "जानकारी", + "mod_epicloot_min": "न्यूनतम", + "mod_epicloot_max": "अधिकतम", + "mod_epicloot_increment": "वृद्धि", + "mod_epicloot_minmax": "न्यूनतम/अधिकतम", + "mod_epicloot_successchance": "सफलता की संभावना", + "mod_epicloot_criticalsuccess": "गंभीर सफलता", + "mod_epicloot_criticalfail": "गंभीर विफलता", + "mod_epicloot_temper": "कठोरीकरण" } \ No newline at end of file diff --git a/EpicLoot/localizations/Hungarian.json b/EpicLoot/localizations/Hungarian.json index 3ad083544..5fe0956a5 100644 --- a/EpicLoot/localizations/Hungarian.json +++ b/EpicLoot/localizations/Hungarian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Keresés...", "mod_epicloot_no_active_adventures": "Nincs aktív kaland", "mod_epicloot_set_bonuses": "Szett bónuszok", - "mod_epicloot_legendary_sets": "Legendás készletek" + "mod_epicloot_legendary_sets": "Legendás készletek", + "mod_epicloot_enchantments": "Bűbájok", + "mod_epicloot_output": "Eredmény", + "mod_epicloot_history": "Előzmények", + "mod_epicloot_requirements": "Követelmények", + "mod_epicloot_info": "Információ", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Növekmény", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Siker esélye", + "mod_epicloot_criticalsuccess": "Kritikus siker", + "mod_epicloot_criticalfail": "Kritikus kudarc", + "mod_epicloot_temper": "Edzés" } \ No newline at end of file diff --git a/EpicLoot/localizations/Icelandic.json b/EpicLoot/localizations/Icelandic.json index d07dd387b..65f7f2fd2 100644 --- a/EpicLoot/localizations/Icelandic.json +++ b/EpicLoot/localizations/Icelandic.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Leita...", "mod_epicloot_no_active_adventures": "Engar virkar ævintýri", "mod_epicloot_set_bonuses": "Settafbótir", - "mod_epicloot_legendary_sets": "Goðsagnakennd sett" + "mod_epicloot_legendary_sets": "Goðsagnakennd sett", + "mod_epicloot_enchantments": "Töfranir", + "mod_epicloot_output": "Niðurstaða", + "mod_epicloot_history": "Saga", + "mod_epicloot_requirements": "Kröfur", + "mod_epicloot_info": "Upplýsingar", + "mod_epicloot_min": "Lágm", + "mod_epicloot_max": "Hám", + "mod_epicloot_increment": "Aukning", + "mod_epicloot_minmax": "Lágm/Hám", + "mod_epicloot_successchance": "Líkur á árangri", + "mod_epicloot_criticalsuccess": "Gagnrýninn árangur", + "mod_epicloot_criticalfail": "Gagnrýninn mistök", + "mod_epicloot_temper": "Hersla" } \ No newline at end of file diff --git a/EpicLoot/localizations/Italian.json b/EpicLoot/localizations/Italian.json index fa878a7df..0ed10d94e 100644 --- a/EpicLoot/localizations/Italian.json +++ b/EpicLoot/localizations/Italian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Cerca...", "mod_epicloot_no_active_adventures": "Nessuna avventura attiva", "mod_epicloot_set_bonuses": "Bonus set", - "mod_epicloot_legendary_sets": "Set Leggendari" + "mod_epicloot_legendary_sets": "Set Leggendari", + "mod_epicloot_enchantments": "Incantamenti", + "mod_epicloot_output": "Risultato", + "mod_epicloot_history": "Cronologia", + "mod_epicloot_requirements": "Requisiti", + "mod_epicloot_info": "Informazioni", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Incremento", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Probabilità di successo", + "mod_epicloot_criticalsuccess": "Successo critico", + "mod_epicloot_criticalfail": "Fallimento critico", + "mod_epicloot_temper": "Tempra" } \ No newline at end of file diff --git a/EpicLoot/localizations/Japanese.json b/EpicLoot/localizations/Japanese.json index 8f23f63a6..caab61d02 100644 --- a/EpicLoot/localizations/Japanese.json +++ b/EpicLoot/localizations/Japanese.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "検索...", "mod_epicloot_no_active_adventures": "アクティブな冒険はありません", "mod_epicloot_set_bonuses": "セットボーナス", - "mod_epicloot_legendary_sets": "伝説のセット" + "mod_epicloot_legendary_sets": "伝説のセット", + "mod_epicloot_enchantments": "エンチャント", + "mod_epicloot_output": "結果", + "mod_epicloot_history": "履歴", + "mod_epicloot_requirements": "必要条件", + "mod_epicloot_info": "情報", + "mod_epicloot_min": "最小", + "mod_epicloot_max": "最大", + "mod_epicloot_increment": "増加量", + "mod_epicloot_minmax": "最小/最大", + "mod_epicloot_successchance": "成功率", + "mod_epicloot_criticalsuccess": "クリティカル成功", + "mod_epicloot_criticalfail": "クリティカル失敗", + "mod_epicloot_temper": "鍛錬" } \ No newline at end of file diff --git a/EpicLoot/localizations/Korean.json b/EpicLoot/localizations/Korean.json index 2e2b0aced..d715d0daa 100644 --- a/EpicLoot/localizations/Korean.json +++ b/EpicLoot/localizations/Korean.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "검색...", "mod_epicloot_no_active_adventures": "활성화된 모험이 없습니다", "mod_epicloot_set_bonuses": "세트 보너스", - "mod_epicloot_legendary_sets": "전설 세트" + "mod_epicloot_legendary_sets": "전설 세트", + "mod_epicloot_enchantments": "마법 부여", + "mod_epicloot_output": "결과", + "mod_epicloot_history": "기록", + "mod_epicloot_requirements": "요구 사항", + "mod_epicloot_info": "정보", + "mod_epicloot_min": "최소", + "mod_epicloot_max": "최대", + "mod_epicloot_increment": "증가량", + "mod_epicloot_minmax": "최소/최대", + "mod_epicloot_successchance": "성공 확률", + "mod_epicloot_criticalsuccess": "치명적 성공", + "mod_epicloot_criticalfail": "치명적 실패", + "mod_epicloot_temper": "담금질" } \ No newline at end of file diff --git a/EpicLoot/localizations/Latvian.json b/EpicLoot/localizations/Latvian.json index 6cad3fe55..ebe49cd27 100644 --- a/EpicLoot/localizations/Latvian.json +++ b/EpicLoot/localizations/Latvian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Meklēt...", "mod_epicloot_no_active_adventures": "Nav aktīvu piedzīvojumu", "mod_epicloot_set_bonuses": "Komplekta bonusi", - "mod_epicloot_legendary_sets": "Leģendārās komplekti" + "mod_epicloot_legendary_sets": "Leģendārās komplekti", + "mod_epicloot_enchantments": "Apbūrumi", + "mod_epicloot_output": "Rezultāts", + "mod_epicloot_history": "Vēsture", + "mod_epicloot_requirements": "Prasības", + "mod_epicloot_info": "Informācija", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Pieaugums", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Veiksmes iespēja", + "mod_epicloot_criticalsuccess": "Kritisks panākums", + "mod_epicloot_criticalfail": "Kritiska kļūme", + "mod_epicloot_temper": "Rūdīšana" } \ No newline at end of file diff --git a/EpicLoot/localizations/Lithuanian.json b/EpicLoot/localizations/Lithuanian.json index fa90e0463..1003d6e56 100644 --- a/EpicLoot/localizations/Lithuanian.json +++ b/EpicLoot/localizations/Lithuanian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Ieškoti...", "mod_epicloot_no_active_adventures": "Nėra aktyvių nuotykių", "mod_epicloot_set_bonuses": "Rinkinys premijos", - "mod_epicloot_legendary_sets": "Legendinės rinkiniai" + "mod_epicloot_legendary_sets": "Legendinės rinkiniai", + "mod_epicloot_enchantments": "Užkeikimai", + "mod_epicloot_output": "Rezultatas", + "mod_epicloot_history": "Istorija", + "mod_epicloot_requirements": "Reikalavimai", + "mod_epicloot_info": "Informacija", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Prieaugis", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Sėkmės tikimybė", + "mod_epicloot_criticalsuccess": "Kritinė sėkmė", + "mod_epicloot_criticalfail": "Kritinė nesėkmė", + "mod_epicloot_temper": "Grūdinimas" } \ No newline at end of file diff --git a/EpicLoot/localizations/Macedonian.json b/EpicLoot/localizations/Macedonian.json index 4f5b575d3..dbb7e952d 100644 --- a/EpicLoot/localizations/Macedonian.json +++ b/EpicLoot/localizations/Macedonian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Пребарај...", "mod_epicloot_no_active_adventures": "Нема активни авантури", "mod_epicloot_set_bonuses": "Бонуси од сет", - "mod_epicloot_legendary_sets": "Легендарни сетови" + "mod_epicloot_legendary_sets": "Легендарни сетови", + "mod_epicloot_enchantments": "Маѓепсувања", + "mod_epicloot_output": "Резултат", + "mod_epicloot_history": "Историја", + "mod_epicloot_requirements": "Барања", + "mod_epicloot_info": "Информации", + "mod_epicloot_min": "Мин", + "mod_epicloot_max": "Макс", + "mod_epicloot_increment": "Зголемување", + "mod_epicloot_minmax": "Мин/Макс", + "mod_epicloot_successchance": "Шанса за успех", + "mod_epicloot_criticalsuccess": "Критичен успех", + "mod_epicloot_criticalfail": "Критичен неуспех", + "mod_epicloot_temper": "Калење" } \ No newline at end of file diff --git a/EpicLoot/localizations/Norwegian.json b/EpicLoot/localizations/Norwegian.json index f495e6971..d16703379 100644 --- a/EpicLoot/localizations/Norwegian.json +++ b/EpicLoot/localizations/Norwegian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Søk...", "mod_epicloot_no_active_adventures": "Ingen aktive eventyr", "mod_epicloot_set_bonuses": "Settbonuser", - "mod_epicloot_legendary_sets": "Legendariske sett" + "mod_epicloot_legendary_sets": "Legendariske sett", + "mod_epicloot_enchantments": "Fortryllelser", + "mod_epicloot_output": "Resultat", + "mod_epicloot_history": "Historikk", + "mod_epicloot_requirements": "Krav", + "mod_epicloot_info": "Informasjon", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Økning", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Sjanse for suksess", + "mod_epicloot_criticalsuccess": "Kritisk suksess", + "mod_epicloot_criticalfail": "Kritisk feil", + "mod_epicloot_temper": "Herding" } \ No newline at end of file diff --git a/EpicLoot/localizations/Polish.json b/EpicLoot/localizations/Polish.json index 6d16a1837..1732c67db 100644 --- a/EpicLoot/localizations/Polish.json +++ b/EpicLoot/localizations/Polish.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Szukaj...", "mod_epicloot_no_active_adventures": "Brak aktywnych przygód", "mod_epicloot_set_bonuses": "Bonusy zestawu", - "mod_epicloot_legendary_sets": "Legendarne zestawy" + "mod_epicloot_legendary_sets": "Legendarne zestawy", + "mod_epicloot_enchantments": "Zaklęcia", + "mod_epicloot_output": "Wynik", + "mod_epicloot_history": "Historia", + "mod_epicloot_requirements": "Wymagania", + "mod_epicloot_info": "Informacje", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Przyrost", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Szansa na sukces", + "mod_epicloot_criticalsuccess": "Krytyczny sukces", + "mod_epicloot_criticalfail": "Krytyczna porażka", + "mod_epicloot_temper": "Hartowanie" } \ No newline at end of file diff --git a/EpicLoot/localizations/Portuguese_Brazilian.json b/EpicLoot/localizations/Portuguese_Brazilian.json index acdad332c..edecab4ea 100644 --- a/EpicLoot/localizations/Portuguese_Brazilian.json +++ b/EpicLoot/localizations/Portuguese_Brazilian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Pesquisar...", "mod_epicloot_no_active_adventures": "Nenhuma aventura ativa", "mod_epicloot_set_bonuses": "Bônus de conjunto", - "mod_epicloot_legendary_sets": "Conjuntos Lendários" + "mod_epicloot_legendary_sets": "Conjuntos Lendários", + "mod_epicloot_enchantments": "Encantamentos", + "mod_epicloot_output": "Resultado", + "mod_epicloot_history": "Histórico", + "mod_epicloot_requirements": "Requisitos", + "mod_epicloot_info": "Informações", + "mod_epicloot_min": "Mín", + "mod_epicloot_max": "Máx", + "mod_epicloot_increment": "Incremento", + "mod_epicloot_minmax": "Mín/Máx", + "mod_epicloot_successchance": "Chance de Sucesso", + "mod_epicloot_criticalsuccess": "Sucesso Crítico", + "mod_epicloot_criticalfail": "Falha Crítica", + "mod_epicloot_temper": "Têmpera" } \ No newline at end of file diff --git a/EpicLoot/localizations/Portuguese_European.json b/EpicLoot/localizations/Portuguese_European.json index 132566880..a4ca6c402 100644 --- a/EpicLoot/localizations/Portuguese_European.json +++ b/EpicLoot/localizations/Portuguese_European.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Pesquisar...", "mod_epicloot_no_active_adventures": "Nenhuma aventura ativa", "mod_epicloot_set_bonuses": "Bónus de conjunto", - "mod_epicloot_legendary_sets": "Conjuntos Lendários" + "mod_epicloot_legendary_sets": "Conjuntos Lendários", + "mod_epicloot_enchantments": "Encantamentos", + "mod_epicloot_output": "Resultado", + "mod_epicloot_history": "Histórico", + "mod_epicloot_requirements": "Requisitos", + "mod_epicloot_info": "Informações", + "mod_epicloot_min": "Mín", + "mod_epicloot_max": "Máx", + "mod_epicloot_increment": "Incremento", + "mod_epicloot_minmax": "Mín/Máx", + "mod_epicloot_successchance": "Probabilidade de Sucesso", + "mod_epicloot_criticalsuccess": "Sucesso Crítico", + "mod_epicloot_criticalfail": "Falha Crítica", + "mod_epicloot_temper": "Têmpera" } \ No newline at end of file diff --git a/EpicLoot/localizations/Romanian.json b/EpicLoot/localizations/Romanian.json index 3e62851d2..8ca7f194e 100644 --- a/EpicLoot/localizations/Romanian.json +++ b/EpicLoot/localizations/Romanian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Căutare...", "mod_epicloot_no_active_adventures": "Nu există aventuri active", "mod_epicloot_set_bonuses": "Bonusuri set", - "mod_epicloot_legendary_sets": "Seturi legendare" + "mod_epicloot_legendary_sets": "Seturi legendare", + "mod_epicloot_enchantments": "Descântece", + "mod_epicloot_output": "Rezultat", + "mod_epicloot_history": "Istoric", + "mod_epicloot_requirements": "Cerințe", + "mod_epicloot_info": "Informații", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Creștere", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Șansă de succes", + "mod_epicloot_criticalsuccess": "Succes critic", + "mod_epicloot_criticalfail": "Eșec critic", + "mod_epicloot_temper": "Călire" } \ No newline at end of file diff --git a/EpicLoot/localizations/Russian.json b/EpicLoot/localizations/Russian.json index ac0fe1e78..c2a68b28e 100644 --- a/EpicLoot/localizations/Russian.json +++ b/EpicLoot/localizations/Russian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Поиск...", "mod_epicloot_no_active_adventures": "Активных приключений нет", "mod_epicloot_set_bonuses": "Бонусы комплекта", - "mod_epicloot_legendary_sets": "Легендарные наборы" + "mod_epicloot_legendary_sets": "Легендарные наборы", + "mod_epicloot_enchantments": "Зачарования", + "mod_epicloot_output": "Результат", + "mod_epicloot_history": "История", + "mod_epicloot_requirements": "Требования", + "mod_epicloot_info": "Информация", + "mod_epicloot_min": "Мин", + "mod_epicloot_max": "Макс", + "mod_epicloot_increment": "Прибавление", + "mod_epicloot_minmax": "Мин/Макс", + "mod_epicloot_successchance": "Шанс успеха", + "mod_epicloot_criticalsuccess": "Критический успех", + "mod_epicloot_criticalfail": "Критический провал", + "mod_epicloot_temper": "Закалка" } \ No newline at end of file diff --git a/EpicLoot/localizations/Serbian.json b/EpicLoot/localizations/Serbian.json index b3982ad89..af733be58 100644 --- a/EpicLoot/localizations/Serbian.json +++ b/EpicLoot/localizations/Serbian.json @@ -1449,5 +1449,18 @@ "mod_epicloot_search_placeholder": "Претрага...", "mod_epicloot_no_active_adventures": "Нема активних авантура", "mod_epicloot_set_bonuses": "Бонуси сета", - "mod_epicloot_legendary_sets": "Легендарни сетови" + "mod_epicloot_legendary_sets": "Легендарни сетови", + "mod_epicloot_enchantments": "Чаролије", + "mod_epicloot_output": "Резултат", + "mod_epicloot_history": "Историја", + "mod_epicloot_requirements": "Захтеви", + "mod_epicloot_info": "Информације", + "mod_epicloot_min": "Мин", + "mod_epicloot_max": "Макс", + "mod_epicloot_increment": "Повећање", + "mod_epicloot_minmax": "Мин/Макс", + "mod_epicloot_successchance": "Шанса за успех", + "mod_epicloot_criticalsuccess": "Критичан успех", + "mod_epicloot_criticalfail": "Критичан неуспех", + "mod_epicloot_temper": "Каљење" } \ No newline at end of file diff --git a/EpicLoot/localizations/Slovak.json b/EpicLoot/localizations/Slovak.json index e11b18114..50da9013c 100644 --- a/EpicLoot/localizations/Slovak.json +++ b/EpicLoot/localizations/Slovak.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Hľadať...", "mod_epicloot_no_active_adventures": "Žiadne aktívne dobrodružstvá", "mod_epicloot_set_bonuses": "Bonusy setu", - "mod_epicloot_legendary_sets": "Legendárne sady" + "mod_epicloot_legendary_sets": "Legendárne sady", + "mod_epicloot_enchantments": "Očarovania", + "mod_epicloot_output": "Výsledok", + "mod_epicloot_history": "História", + "mod_epicloot_requirements": "Požiadavky", + "mod_epicloot_info": "Informácie", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Prírastok", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Šanca na úspech", + "mod_epicloot_criticalsuccess": "Kritický úspech", + "mod_epicloot_criticalfail": "Kritické zlyhanie", + "mod_epicloot_temper": "Kalenie" } \ No newline at end of file diff --git a/EpicLoot/localizations/Spanish.json b/EpicLoot/localizations/Spanish.json index 7d7f976f8..fe79ee9e2 100644 --- a/EpicLoot/localizations/Spanish.json +++ b/EpicLoot/localizations/Spanish.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Buscar...", "mod_epicloot_no_active_adventures": "No hay aventuras activas", "mod_epicloot_set_bonuses": "Bonos de conjunto", - "mod_epicloot_legendary_sets": "Conjuntos legendarios" + "mod_epicloot_legendary_sets": "Conjuntos legendarios", + "mod_epicloot_enchantments": "Encantamientos", + "mod_epicloot_output": "Resultado", + "mod_epicloot_history": "Historial", + "mod_epicloot_requirements": "Requisitos", + "mod_epicloot_info": "Información", + "mod_epicloot_min": "Mín", + "mod_epicloot_max": "Máx", + "mod_epicloot_increment": "Incremento", + "mod_epicloot_minmax": "Mín/Máx", + "mod_epicloot_successchance": "Probabilidad de éxito", + "mod_epicloot_criticalsuccess": "Éxito crítico", + "mod_epicloot_criticalfail": "Fallo crítico", + "mod_epicloot_temper": "Temple" } \ No newline at end of file diff --git a/EpicLoot/localizations/Swedish.json b/EpicLoot/localizations/Swedish.json index 06cc0b76c..cd9ef9965 100644 --- a/EpicLoot/localizations/Swedish.json +++ b/EpicLoot/localizations/Swedish.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Sök...", "mod_epicloot_no_active_adventures": "Inga aktiva äventyr", "mod_epicloot_set_bonuses": "Setbonusar", - "mod_epicloot_legendary_sets": "Legendariska set" + "mod_epicloot_legendary_sets": "Legendariska set", + "mod_epicloot_enchantments": "Förbättringar", + "mod_epicloot_output": "Resultat", + "mod_epicloot_history": "Historik", + "mod_epicloot_requirements": "Krav", + "mod_epicloot_info": "Information", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Max", + "mod_epicloot_increment": "Ökning", + "mod_epicloot_minmax": "Min/Max", + "mod_epicloot_successchance": "Chans att lyckas", + "mod_epicloot_criticalsuccess": "Kritisk framgång", + "mod_epicloot_criticalfail": "Kritiskt misslyckande", + "mod_epicloot_temper": "Härdning" } \ No newline at end of file diff --git a/EpicLoot/localizations/Thai.json b/EpicLoot/localizations/Thai.json index ead24076e..7ea5af990 100644 --- a/EpicLoot/localizations/Thai.json +++ b/EpicLoot/localizations/Thai.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "ค้นหา...", "mod_epicloot_no_active_adventures": "ไม่มีการผจญภัยที่กำลังดำเนินอยู่", "mod_epicloot_set_bonuses": "โบนัสชุด", - "mod_epicloot_legendary_sets": "ชุดในตำนาน" + "mod_epicloot_legendary_sets": "ชุดในตำนาน", + "mod_epicloot_enchantments": "การร่ายมนตร์", + "mod_epicloot_output": "ผลลัพธ์", + "mod_epicloot_history": "ประวัติ", + "mod_epicloot_requirements": "ข้อกำหนด", + "mod_epicloot_info": "ข้อมูล", + "mod_epicloot_min": "ต่ำสุด", + "mod_epicloot_max": "สูงสุด", + "mod_epicloot_increment": "การเพิ่ม", + "mod_epicloot_minmax": "ต่ำสุด/สูงสุด", + "mod_epicloot_successchance": "โอกาสสำเร็จ", + "mod_epicloot_criticalsuccess": "ความสำเร็จขั้นวิกฤต", + "mod_epicloot_criticalfail": "ความล้มเหลวขั้นวิกฤต", + "mod_epicloot_temper": "การชุบแข็ง" } \ No newline at end of file diff --git a/EpicLoot/localizations/Turkish.json b/EpicLoot/localizations/Turkish.json index f9b594a00..1cae2c2f6 100644 --- a/EpicLoot/localizations/Turkish.json +++ b/EpicLoot/localizations/Turkish.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Ara...", "mod_epicloot_no_active_adventures": "Aktif macera yok", "mod_epicloot_set_bonuses": "Set Bonusları", - "mod_epicloot_legendary_sets": "Efsanevi Setler" + "mod_epicloot_legendary_sets": "Efsanevi Setler", + "mod_epicloot_enchantments": "Büyüler", + "mod_epicloot_output": "Sonuç", + "mod_epicloot_history": "Geçmiş", + "mod_epicloot_requirements": "Gereksinimler", + "mod_epicloot_info": "Bilgi", + "mod_epicloot_min": "Min", + "mod_epicloot_max": "Maks", + "mod_epicloot_increment": "Artış", + "mod_epicloot_minmax": "Min/Maks", + "mod_epicloot_successchance": "Başarı Şansı", + "mod_epicloot_criticalsuccess": "Kritik Başarı", + "mod_epicloot_criticalfail": "Kritik Başarısızlık", + "mod_epicloot_temper": "Su Verme" } \ No newline at end of file diff --git a/EpicLoot/localizations/Ukrainian.json b/EpicLoot/localizations/Ukrainian.json index fdeddcf70..f0bfcd5b3 100644 --- a/EpicLoot/localizations/Ukrainian.json +++ b/EpicLoot/localizations/Ukrainian.json @@ -1448,5 +1448,18 @@ "mod_epicloot_search_placeholder": "Пошук...", "mod_epicloot_no_active_adventures": "Немає активних пригод", "mod_epicloot_set_bonuses": "Бонуси комплекту", - "mod_epicloot_legendary_sets": "Легендарні набори" + "mod_epicloot_legendary_sets": "Легендарні набори", + "mod_epicloot_enchantments": "Зачарування", + "mod_epicloot_output": "Результат", + "mod_epicloot_history": "Історія", + "mod_epicloot_requirements": "Вимоги", + "mod_epicloot_info": "Інформація", + "mod_epicloot_min": "Мін", + "mod_epicloot_max": "Макс", + "mod_epicloot_increment": "Приріст", + "mod_epicloot_minmax": "Мін/Макс", + "mod_epicloot_successchance": "Шанс успіху", + "mod_epicloot_criticalsuccess": "Критичний успіх", + "mod_epicloot_criticalfail": "Критичний провал", + "mod_epicloot_temper": "Гартування" } \ No newline at end of file diff --git a/EpicLoot/reset_select_language_entries.rb b/EpicLoot/reset_select_language_entries.rb deleted file mode 100644 index 7fc836266..000000000 --- a/EpicLoot/reset_select_language_entries.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require "json" - -### -# This script can clean out localization keys from non-English localization files -# this can be used to reset or update existing translations -# keys_to_remove below should be set to the key entries you want removed, if a key does not exist it is skipped -# this script can be ran by `ruby reset_select_language_files.rb`, it does not have any dependencies -# - -language_files = Dir["localizations/*"] -keys_to_remove = %w[item_meteor_atgeir] - -language_files.each do |lang_file| - next if lang_file == "localizations/English.json" - - lang_json = JSON.parse(File.read("#{lang_file}")) - puts "Removing keys from #{lang_file}" - keys_to_remove.each do |rm_key| - lang_json.delete(rm_key) - end - File.open("#{lang_file}", "w") { |f| f.write(JSON.pretty_generate(lang_json)) } -end diff --git a/EpicLoot/src/Adventure/AdventureComponent.cs b/EpicLoot/src/Adventure/AdventureComponent.cs index 6c376360e..d3d7f52a9 100644 --- a/EpicLoot/src/Adventure/AdventureComponent.cs +++ b/EpicLoot/src/Adventure/AdventureComponent.cs @@ -30,28 +30,21 @@ public void Load() _player.m_knownTexts.Remove(SaveDataKey); } - + if (_player.m_customData.TryGetValue(SaveDataKey, out var data)) { - try + SaveData = Deserialize(data); + + // Clean up old bounties + var removed = 0; + foreach (var saveData in SaveData.AllSaveData) { - SaveData = JsonConvert.DeserializeObject(data); - - // Clean up old bounties - var removed = 0; - foreach (var saveData in SaveData.AllSaveData) - { - removed += saveData.Bounties.RemoveAll(x => x.State == BountyState.InProgress && x.PlayerID == 0); - } - - if (removed > 0) - { - EpicLoot.LogWarning($"Removed {removed} invalid bounties"); - } + removed += saveData.Bounties.RemoveAll(x => x.State == BountyState.InProgress && x.PlayerID == 0); } - catch (Exception) + + if (removed > 0) { - SaveData = new AdventureSaveDataList(); + EpicLoot.LogWarning($"Removed {removed} invalid bounties"); } } else @@ -60,10 +53,68 @@ public void Load() } } + /// + /// Reads the save blob. New saves are a base64-encoded ZPackage; legacy saves are JSON + /// (which starts with '{'). Legacy blobs are upgraded transparently on the next Save(). + /// + private static AdventureSaveDataList Deserialize(string data) + { + if (string.IsNullOrEmpty(data)) + { + return new AdventureSaveDataList(); + } + + if (!data.StartsWith("{")) + { + try + { + return AdventureSaveDataList.FromPackage(new ZPackage(data)); + } + catch (Exception) + { + // Not a valid binary blob; fall back to the legacy JSON path below. + } + } + + try + { + return JsonConvert.DeserializeObject(data) ?? new AdventureSaveDataList(); + } + catch (Exception) + { + return new AdventureSaveDataList(); + } + } + public void Save() { - var data = JsonConvert.SerializeObject(SaveData, Formatting.None); - _player.m_customData[SaveDataKey] = data; + PruneStaleRecords(); + + var pkg = new ZPackage(); + SaveData.ToPackage(pkg); + pkg.SetPos(0); + _player.m_customData[SaveDataKey] = pkg.GetBase64(); + } + + /// + /// Drops finished records from elapsed intervals before serializing. Only runs with the + /// world loaded, since GetCurrentInterval() dereferences EnvMan.instance. + /// + private void PruneStaleRecords() + { + if (ZNet.m_world == null || EnvMan.instance == null + || AdventureDataManager.Bounties == null || AdventureDataManager.TreasureMaps == null) + { + return; + } + + var currentBountyInterval = AdventureDataManager.Bounties.GetCurrentInterval(); + var currentTreasureInterval = AdventureDataManager.TreasureMaps.GetCurrentInterval(); + + foreach (var saveData in SaveData.AllSaveData) + { + saveData.PruneStaleRecords(currentBountyInterval, currentTreasureInterval); + } } } } diff --git a/EpicLoot/src/Adventure/AdventureDataConfig.cs b/EpicLoot/src/Adventure/AdventureDataConfig.cs index ce1c531ad..20d480050 100644 --- a/EpicLoot/src/Adventure/AdventureDataConfig.cs +++ b/EpicLoot/src/Adventure/AdventureDataConfig.cs @@ -82,6 +82,14 @@ public class TreasureMapConfig [Obsolete] // TODO evaluate if should keep public float RadiusInterval = 500; public float MinimapAreaRadius = 100; + /// + /// How many times the adventure spawn search may push its sampling ring further out when + /// everything inside the map circle is blocked (almost always by a ward). Each band steps out + /// by one , which is the minimum that can escape a ward's + /// veto, since a ward rejects points within its own radius + MinimapAreaRadius. Set to 0 to + /// restore the old behaviour of never searching outside the circle. + /// + public int MaxSpawnSearchExpansions = 5; public List SaleItems = new List(); [NonSerialized] diff --git a/EpicLoot/src/Adventure/AdventureSaveData.cs b/EpicLoot/src/Adventure/AdventureSaveData.cs index e7fc88f61..21c00c56e 100755 --- a/EpicLoot/src/Adventure/AdventureSaveData.cs +++ b/EpicLoot/src/Adventure/AdventureSaveData.cs @@ -22,6 +22,28 @@ public class TreasureMapChestInfo public SerializableVector3 Position; public SerializableVector3 MinimapCircleOffset; public long PlayerID; + + public void ToPackage(ZPackage pkg) + { + pkg.Write(Interval); + pkg.Write((int)Biome); + pkg.Write((int)State); + Position.ToPackage(pkg); + MinimapCircleOffset.ToPackage(pkg); + pkg.Write(PlayerID); + } + + public static TreasureMapChestInfo FromPackage(ZPackage pkg) + { + var result = new TreasureMapChestInfo(); + result.Interval = pkg.ReadInt(); + result.Biome = (Heightmap.Biome)pkg.ReadInt(); + result.State = (TreasureMapState)pkg.ReadInt(); + result.Position = SerializableVector3.FromPackage(pkg); + result.MinimapCircleOffset = SerializableVector3.FromPackage(pkg); + result.PlayerID = pkg.ReadLong(); + return result; + } } [Serializable] @@ -134,7 +156,33 @@ public static BountyInfo FromPackage(ZPackage pkg) [Serializable] public class AdventureSaveDataList { + // Format version for the compact binary save. Bump when the layout changes. + public const int Version = 1; + public List AllSaveData = new List(); + + public void ToPackage(ZPackage pkg) + { + pkg.Write(Version); + pkg.Write(AllSaveData.Count); + foreach (var saveData in AllSaveData) + { + saveData.ToPackage(pkg); + } + } + + public static AdventureSaveDataList FromPackage(ZPackage pkg) + { + var result = new AdventureSaveDataList(); + pkg.ReadInt(); // Version (reserved for future format branching) + var count = pkg.ReadInt(); + result.AllSaveData = new List(count); + for (var index = 0; index < count; index++) + { + result.AllSaveData.Add(AdventureSaveData.FromPackage(pkg)); + } + return result; + } } [Serializable] @@ -148,6 +196,66 @@ public class AdventureSaveData [NonSerialized] public bool DebugMode; [NonSerialized] public int IntervalOverride; + public void ToPackage(ZPackage pkg) + { + pkg.Write(WorldID); + pkg.Write(NumberOfTreasureMapsOrBountiesStarted); + + pkg.Write(TreasureMaps.Count); + foreach (var treasureMap in TreasureMaps) + { + treasureMap.ToPackage(pkg); + } + + pkg.Write(Bounties.Count); + foreach (var bounty in Bounties) + { + bounty.ToPackage(pkg); + } + } + + public static AdventureSaveData FromPackage(ZPackage pkg) + { + var result = new AdventureSaveData(); + result.WorldID = pkg.ReadLong(); + result.NumberOfTreasureMapsOrBountiesStarted = pkg.ReadInt(); + + var treasureMapCount = pkg.ReadInt(); + result.TreasureMaps = new List(treasureMapCount); + for (var index = 0; index < treasureMapCount; index++) + { + result.TreasureMaps.Add(TreasureMapChestInfo.FromPackage(pkg)); + } + + var bountyCount = pkg.ReadInt(); + result.Bounties = new List(bountyCount); + for (var index = 0; index < bountyCount; index++) + { + result.Bounties.Add(BountyInfo.FromPackage(pkg)); + } + + return result; + } + + /// + /// Drops terminal-state records from intervals that have already elapsed. The board and + /// shop regenerate deterministically per interval, so past-interval finished records are + /// never read again. Current-interval records are kept: pruning them would let the same + /// map/bounty reappear as available this interval. + /// + public int PruneStaleRecords(int currentBountyInterval, int currentTreasureInterval) + { + var removed = Bounties.RemoveAll(x => + (x.State == BountyState.Claimed || x.State == BountyState.Abandoned) + && x.Interval < currentBountyInterval); + + removed += TreasureMaps.RemoveAll(x => + x.State == TreasureMapState.Found + && x.Interval < currentTreasureInterval); + + return removed; + } + public bool PurchasedTreasureMap(TreasureMapChestInfo chestInfo) { if (!DebugMode) @@ -214,6 +322,55 @@ public bool FoundTreasureChest(int interval, Heightmap.Biome biome) return false; } + /// + /// Moves a purchased-but-unfound treasure map to a new world position and drags its minimap + /// pin along with it. Called when the spawner had to search outside the original map circle + /// (almost always because a ward covered it) - without this the pin would keep pointing at a + /// spot the chest is not in. + /// + public bool RelocateTreasureMap(int interval, Heightmap.Biome biome, Vector3 newPosition) + { + var treasureMap = GetTreasureMapChestInfo(interval, biome); + if (treasureMap == null || treasureMap.State != TreasureMapState.Purchased) + { + return false; + } + + treasureMap.Position = newPosition; + // The circle is re-centred on the chest, so any old offset would just skew it back. + treasureMap.MinimapCircleOffset = Vector3.zero; + + var key = new Tuple(interval, biome); + if (MinimapController.TreasureMapPins.TryGetValue(key, out var existingPin)) + { + // The queue is drained FIFO, so remove-then-add is a move. + MinimapController.AddPinJobToQueue(new PinJob + { + Task = MinimapPinQueueTask.RemoveTreasurePin, + DebugMode = DebugMode, + TreasurePin = new KeyValuePair, AreaPinInfo>(key, existingPin) + }); + } + + var pinInfo = new AreaPinInfo + { + Position = treasureMap.Position + treasureMap.MinimapCircleOffset, + Type = EpicLoot.TreasureMapPinType, + Name = Localization.instance.Localize("$mod_epicloot_treasurechest_minimappin", + Localization.instance.Localize($"$biome_{biome.ToString().ToLowerInvariant()}"), + (interval + 1).ToString()) + }; + + MinimapController.AddPinJobToQueue(new PinJob + { + Task = MinimapPinQueueTask.AddTreasurePin, + DebugMode = DebugMode, + TreasurePin = new KeyValuePair, AreaPinInfo>(key, pinInfo) + }); + + return true; + } + public TreasureMapChestInfo GetTreasureMapChestInfo(int interval, Heightmap.Biome biome) { return TreasureMaps.Find(x => x.Interval == interval && x.Biome == biome); @@ -304,5 +461,49 @@ public void AbandonedBounty(string bountyID) bounty.State = BountyState.Abandoned; } } + + /// + /// Bounty counterpart to : moves an in-progress bounty's + /// world position and its minimap pin when the spawner had to place the targets outside the + /// original circle. + /// + public bool RelocateBounty(string bountyID, Vector3 newPosition) + { + var bounty = GetBountyInfoByID(bountyID); + if (bounty == null || bounty.State != BountyState.InProgress) + { + return false; + } + + bounty.Position = newPosition; + bounty.MinimapCircleOffset = Vector3.zero; + + if (MinimapController.BountyPins.TryGetValue(bountyID, out var existingPin)) + { + MinimapController.AddPinJobToQueue(new PinJob + { + Task = MinimapPinQueueTask.RemoveBountyPin, + DebugMode = DebugMode, + BountyPin = new KeyValuePair(bountyID, existingPin) + }); + } + + var pinInfo = new AreaPinInfo + { + Position = bounty.Position + bounty.MinimapCircleOffset, + Type = EpicLoot.BountyPinType, + Name = Localization.instance.Localize("$mod_epicloot_bounties_minimappin", + AdventureDataManager.GetBountyName(bounty)) + }; + + MinimapController.AddPinJobToQueue(new PinJob + { + Task = MinimapPinQueueTask.AddBountyPin, + DebugMode = DebugMode, + BountyPin = new KeyValuePair(bountyID, pinInfo) + }); + + return true; + } } } diff --git a/EpicLoot/src/Adventure/AdventureWardCheck.cs b/EpicLoot/src/Adventure/AdventureWardCheck.cs new file mode 100644 index 000000000..8ad3c43b3 --- /dev/null +++ b/EpicLoot/src/Adventure/AdventureWardCheck.cs @@ -0,0 +1,51 @@ +using UnityEngine; + +namespace EpicLoot.Adventure +{ + /// + /// The single definition of "this adventure spawn point is too close to a player's ward". + /// Used twice on different timescales: once when the world point is first picked + /// (), and again when the + /// spawner actually places its contents. Both are needed - the early pick force-spawns the zone + /// with , which does not deliver player-built ZDOs, so + /// is effectively empty for a remote zone at that point. + /// + internal static class AdventureWardCheck + { + /// + /// True when an enabled ward covers once its radius is + /// grown by . The test matters: + /// a deactivated guard stone protects nothing in vanilla, so it must not veto a spawn either. + /// Every vanilla call site pairs the two the same way (PrivateArea.CheckAccess, OnObjectDamaged, + /// GetNearbyAreas). + /// + internal static bool TryFindNearbyWard(Vector3 location, float buffer, out PrivateArea ward) + { + foreach (PrivateArea privateArea in PrivateArea.m_allAreas) + { + if (privateArea != null && privateArea.IsEnabled() && privateArea.IsInside(location, buffer)) + { + ward = privateArea; + return true; + } + } + + ward = null; + return false; + } + + /// + /// Describes for a log line. Null-safe so callers can log a miss. + /// + internal static string DescribeWard(PrivateArea ward) + { + if (ward == null) + { + return "none"; + } + + Vector3 position = ward.transform.position; + return $"({position.x:0.##}, {position.y:0.##}, {position.z:0.##}) radius={ward.m_radius:0.##}"; + } + } +} diff --git a/EpicLoot/src/Adventure/MerchantPanel.cs b/EpicLoot/src/Adventure/MerchantPanel.cs index 09a12fbad..0e3ad2487 100755 --- a/EpicLoot/src/Adventure/MerchantPanel.cs +++ b/EpicLoot/src/Adventure/MerchantPanel.cs @@ -1,10 +1,12 @@ using EpicLoot.Adventure.Feature; +using EpicLoot.Config; using EpicLoot.Crafting; using EpicLoot_UnityLib; using System; using System.Collections.Generic; using TMPro; using UnityEngine; +using UnityEngine.EventSystems; using UnityEngine.UI; namespace EpicLoot.Adventure @@ -37,7 +39,7 @@ public Currencies Clone() } } - public class MerchantPanel : MonoBehaviour + public class MerchantPanel : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { public readonly List Panels = new List(); @@ -57,6 +59,9 @@ public class MerchantPanel : MonoBehaviour private static MerchantPanel _instance; private AudioSource _audioSource; + private RectTransform _rt; + private Vector2 _dragOffset; + public void Awake() { _instance = this; @@ -219,6 +224,18 @@ public void Awake() EpicLootAuga.FixupScrollbar(scrollbar); } } + + // Capture the prefab baseline position, make the panel draggable, and apply the + // configured horizontal position. Done at the end of Awake so the root Graphic + // (which may be swapped by the Auga background replacement above) is final. + _rt = (RectTransform)transform; + var backgroundImage = GetComponent(); + if (backgroundImage != null) + { + backgroundImage.raycastTarget = true; + } + + ApplyConfiguredPosition(); } public void OnEnable() @@ -253,6 +270,43 @@ public void OnDestroy() _instance = null; } + public void ApplyConfiguredPosition() + { + if (_rt == null) + { + return; + } + + _rt.anchoredPosition = new Vector2(ELConfig.TraderPanelPositionX.Value, ELConfig.TraderPanelPositionY.Value); + } + + public void OnBeginDrag(PointerEventData eventData) + { + var parent = (RectTransform)_rt.parent; + if (RectTransformUtility.ScreenPointToLocalPointInRectangle( + parent, eventData.position, eventData.pressEventCamera, out var localPoint)) + { + _dragOffset = _rt.anchoredPosition - localPoint; + } + } + + public void OnDrag(PointerEventData eventData) + { + var parent = (RectTransform)_rt.parent; + if (RectTransformUtility.ScreenPointToLocalPointInRectangle( + parent, eventData.position, eventData.pressEventCamera, out var localPoint)) + { + _rt.anchoredPosition = localPoint + _dragOffset; + } + } + + public void OnEndDrag(PointerEventData eventData) + { + // Persists automatically because cfg.SaveOnConfigSet is true. + ELConfig.TraderPanelPositionX.Value = _rt.anchoredPosition.x; + ELConfig.TraderPanelPositionY.Value = _rt.anchoredPosition.y; + } + public Currencies GetPlayerCurrencies() { return _currencies; diff --git a/EpicLoot/src/Adventure/StoreGui_Patch.cs b/EpicLoot/src/Adventure/StoreGui_Patch.cs index e662f6262..084c80fc1 100644 --- a/EpicLoot/src/Adventure/StoreGui_Patch.cs +++ b/EpicLoot/src/Adventure/StoreGui_Patch.cs @@ -7,6 +7,7 @@ namespace EpicLoot.Adventure public static class StoreGui_Patch { public static GameObject MerchantPanel; + public static GameObject TemperPanel; [HarmonyPatch(nameof(StoreGui.Show))] [HarmonyPostfix] @@ -17,6 +18,24 @@ public static void Show_Postfix(StoreGui __instance) return; } + if (__instance.m_trader.m_name == "$npc_hildir") + { + if (__instance.transform.Find(nameof(TemperPanel)) == null) + { + if (TemperPanel != null) + { + Object.Destroy(TemperPanel); + } + global::EpicLoot.TemperPanel.LoadFonts(); + TemperPanel = Object.Instantiate(EpicAssets.TemperPanel, __instance.transform, false); + TemperPanel.name = nameof(TemperPanel); + TemperPanel.AddComponent(); + } + + TemperPanel.GetComponent().Show(Player.m_localPlayer); + return; + } + if (__instance.m_trader.m_name != "$npc_haldor") { //Adds compatibility for other mods that may add other trader NPC's that are not Haldor. @@ -41,6 +60,11 @@ public static void Show_Postfix(StoreGui __instance) [HarmonyPostfix] public static void Hide(StoreGui __instance) { + if (global::EpicLoot.TemperPanel.Instance) + { + global::EpicLoot.TemperPanel.Instance.Hide(); + } + if (MerchantPanel == null) { return; @@ -54,6 +78,7 @@ public static void Hide(StoreGui __instance) public static void OnDestroy(StoreGui __instance) { MerchantPanel = null; + TemperPanel = null; } } } diff --git a/EpicLoot/src/Adventure/TreasureMapChest.cs b/EpicLoot/src/Adventure/TreasureMapChest.cs index 9dd2f9b37..378364d2e 100755 --- a/EpicLoot/src/Adventure/TreasureMapChest.cs +++ b/EpicLoot/src/Adventure/TreasureMapChest.cs @@ -70,6 +70,12 @@ public void Reinitialize(Heightmap.Biome biome, int treasureMapInterval, bool ha container.m_name = Localization.instance.Localize(label); container.m_privacy = hasBeenFound ? Container.PrivacySetting.Public : Container.PrivacySetting.Private; container.m_autoDestroyEmpty = true; + + // The chest can end up inside another player's ward (it spawns at a random point, + // or a ward can be raised over it after the fact) that the buyer isn't permitted on. + // Disable the guard-stone check so the buyer can always reach their purchased + // treasure; access is still restricted to them by the Private privacy setting above. + container.m_checkGuardStone = false; } var piece = GetComponent(); diff --git a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs index 9a72db372..3ce2e72f9 100644 --- a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs +++ b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs @@ -16,7 +16,19 @@ internal class AdventureSpawnController : MonoBehaviour private BoolZNetProperty isBounty { get; set; } + /// Frames to settle before the first search attempt. + private const int InitialSearchDelayFrames = 300; + + /// + /// Frames to wait before re-attempting a search that found nowhere to spawn. Longer than the + /// initial delay because a blocked search usually stays blocked until a ward comes down. + /// + private const int RetrySearchDelayFrames = 1800; + + private const int SpawnAttemptsPerBand = 100; + private int currentUpdates = 0; + private int updatesRequired = InitialSearchDelayFrames; private bool startedPlacement = false; private Vector3 defaultSpawn = new(1, 1, 1); private BountyInfo defaultBounty = new(); @@ -51,7 +63,17 @@ public void Update() return; } - if (currentUpdates < 300) + // A spawner whose ZDO already records a successful placement must never run again. If the + // owner logged out (or the zone unloaded) between placing and the Destroy at the bottom of + // this method, the object comes back with spawnPoint already set, falls straight through + // the searchingForSpawn gate below, and would spawn its contents a second time. + if (placed.Get() == true) + { + ZNetScene.instance.Destroy(this.gameObject); + return; + } + + if (currentUpdates < updatesRequired) { currentUpdates += 1; return; @@ -157,19 +179,25 @@ private void SpawnChest(TreasureMapChestInfo treasure) { Vector3 point = spawnPoint.Get(); - const string treasureChestPrefabName = "piece_chest_wood"; + const string treasureChestPrefabName = "loot_chest_stone"; var treasureChestPrefab = ZNetScene.instance.GetPrefab(treasureChestPrefabName); ZoneSystem.instance.GetGroundData( ref point, out var normal, out var foundBiome, out var biomeArea, out var hmap); var treasureChestObject = UnityEngine.Object.Instantiate( treasureChestPrefab, point, Quaternion.FromToRotation(Vector3.up, normal)); var treasureChest = treasureChestObject.AddComponent(); + + // Dungeon loot chests are not player-built pieces, so Piece may legitimately be absent - + // TreasureMapChest.Reinitialize guards the same way. Piece tpiece = treasureChestObject.GetComponent(); + if (tpiece != null) + { + // Prevent the wildlife from attacking the chest and giving away its location + tpiece.m_primaryTarget = false; + tpiece.m_randomTarget = false; + tpiece.m_targetNonPlayerBuilt = false; + } - // Prevent the wildlife from attacking the chest and giving away its location - tpiece.m_primaryTarget = false; - tpiece.m_randomTarget = false; - tpiece.m_targetNonPlayerBuilt = false; treasureChest.Setup(treasure.PlayerID, treasure.Biome, treasure.Interval); placed.ForceSet(true); } @@ -186,72 +214,122 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, // TODO: If bounties get their own minimap area radius config this must choose the correct one float radius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius; + int maxExpansions = Mathf.Max(0, AdventureDataManager.Config.TreasureMap.MaxSpawnSearchExpansions); Vector3 determinedSpawn = startingSpawnPoint; - int spawnLocationAttempts = 0; + bool foundSpawn = false; + PrivateArea blockingWard = null; - // Attempt to find a spawn point, valid height must be selected - while (spawnLocationAttempts < 100) + // Band 0 is the original search disc. Every band after it is an annulus one MinimapAreaRadius + // further out - the smallest step that can escape a ward, since a ward vetoes everything + // within its own radius + MinimapAreaRadius. + for (int band = 0; band <= maxExpansions && !foundSpawn; band++) { - var offset = UnityEngine.Random.insideUnitCircle * (radius * 0.8f); - determinedSpawn = startingSpawnPoint + new Vector3(offset.x, 0, offset.y); + float innerRadius = band == 0 ? 0f : radius * 0.8f + (band - 1) * radius; + float outerRadius = band == 0 ? radius * 0.8f : radius * 0.8f + band * radius; + + int spawnLocationAttempts = 0; - if (spawnLocationAttempts > 1 && spawnLocationAttempts % 10 == 0) + // Attempt to find a spawn point, valid height must be selected + while (spawnLocationAttempts < SpawnAttemptsPerBand) { - // Sleep to avoid locking the thread - yield return new WaitForSeconds(1f); - } + // Area-uniform sample of the ring, so points do not bunch up against its inner edge. + // For band 0 this is identical to the old Random.insideUnitCircle * (radius * 0.8f). + float sampleRadius = Mathf.Sqrt(Mathf.Lerp(innerRadius * innerRadius, + outerRadius * outerRadius, UnityEngine.Random.value)); + float sampleAngle = UnityEngine.Random.Range(0f, Mathf.PI * 2f); + determinedSpawn = startingSpawnPoint + new Vector3( + Mathf.Cos(sampleAngle) * sampleRadius, 0, Mathf.Sin(sampleAngle) * sampleRadius); + + if (spawnLocationAttempts > 1 && spawnLocationAttempts % 10 == 0) + { + // Sleep to avoid locking the thread + yield return new WaitForSeconds(1f); + } - ZoneSystem.instance.GetGroundData( - ref determinedSpawn, out var normal, out var foundBiome, out var biomeArea, out var hmap); + ZoneSystem.instance.GetGroundData( + ref determinedSpawn, out var normal, out var foundBiome, out var biomeArea, out var hmap); - if (hmap == null || foundBiome != biome) - { - spawnLocationAttempts += 1; - continue; - } + if (hmap == null || foundBiome != biome) + { + spawnLocationAttempts += 1; + continue; + } - float terrainHeight = determinedSpawn.y; - float solidHeight = StartingHeight; + float terrainHeight = determinedSpawn.y; + float solidHeight = StartingHeight; - if (ZoneSystem.instance.FindFloor(new Vector3(determinedSpawn.x, determinedSpawn.y + 100f, determinedSpawn.z), out solidHeight)) - { - float terrainDiff = solidHeight - terrainHeight; + if (ZoneSystem.instance.FindFloor(new Vector3(determinedSpawn.x, determinedSpawn.y + 100f, determinedSpawn.z), out solidHeight)) + { + float terrainDiff = solidHeight - terrainHeight; + + // Prevent spawns in objects and too high off the ground + if (terrainDiff > 0.5f) + { + spawnLocationAttempts += 1; + continue; + } + + if (terrainDiff > 0f) + { + determinedSpawn.y = solidHeight; + } + } + else + { + spawnLocationAttempts += 1; + continue; + } - // Prevent spawns in objects and too high off the ground - if (terrainDiff > 0.5f) + // Prevents spawning in a body of water + if ((biome != Heightmap.Biome.Ocean || !allowWaterSpawn) && + determinedSpawn.y < 29) { spawnLocationAttempts += 1; continue; } - if (terrainDiff > 0f) + // Prevent spawning in Lava unless a last resort + if (biome == Heightmap.Biome.AshLands && + hmap.GetVegetationMask(determinedSpawn) > 0.45f) { - determinedSpawn.y = solidHeight; + spawnLocationAttempts += 1; + continue; } - } - else - { - spawnLocationAttempts += 1; - continue; - } - // Prevents spawning in a body of water - if ((biome != Heightmap.Biome.Ocean || !allowWaterSpawn) && - determinedSpawn.y < 29) - { - spawnLocationAttempts += 1; - continue; + // Keep the spawn out of player bases. Unlike the check made when the world point was + // first picked, the wards around here are actually loaded by now. + if (AdventureWardCheck.TryFindNearbyWard(determinedSpawn, radius, out PrivateArea ward)) + { + blockingWard = ward; + spawnLocationAttempts += 1; + continue; + } + + foundSpawn = true; + break; } - // Prevent spawning in Lava unless a last resort - if (biome == Heightmap.Biome.AshLands && - hmap.GetVegetationMask(determinedSpawn) > 0.45f) + if (!foundSpawn && band < maxExpansions) { - spawnLocationAttempts += 1; - continue; + EpicLoot.LogWarning( + $"No valid adventure spawn point in search band {band} " + + $"({innerRadius:0.##}-{outerRadius:0.##}m); expanding. " + + $"Start=({startingSpawnPoint.x:0.##}, {startingSpawnPoint.y:0.##}, {startingSpawnPoint.z:0.##}), " + + $"Biome={biome}, Ward={AdventureWardCheck.DescribeWard(blockingWard)}"); } + } - break; + if (!foundSpawn) + { + EpicLoot.LogWarning( + "Could not find a valid adventure spawn point after exhausting every search band. " + + $"Start=({startingSpawnPoint.x:0.##}, {startingSpawnPoint.y:0.##}, {startingSpawnPoint.z:0.##}), " + + $"Biome={biome}, SearchRadius={radius:0.##}, Expansions={maxExpansions}, " + + $"Ward={AdventureWardCheck.DescribeWard(blockingWard)}. " + + "Leaving the spawner in place to retry - it is not safe to discard a bounty or " + + "treasure map the player has already paid for."); + ParkAndRetry(); + yield break; } if (determinedSpawn.y >= StartingHeight - 1f) @@ -259,9 +337,104 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, determinedSpawn.y = 400f; } + // A point from an outer band no longer sits under the map marker, so the marker has to + // follow it. Pins live in per-player local save data, so only the player who bought this + // spawn can move theirs - anyone else parks and leaves it for the owner. + if (RequiresPinRelocation(startingSpawnPoint, determinedSpawn) && + !TryRelocateOwnerPin(determinedSpawn)) + { + EpicLoot.Log("Found an adventure spawn point outside the map circle, but the local " + + "player does not own this spawn; leaving it for the owner to place."); + ParkAndRetry(); + yield break; + } + EpicLoot.Log($"Selected Spawn point X {determinedSpawn.x}, Y {determinedSpawn.y}, Z {determinedSpawn.z}"); spawnPoint.ForceSet(determinedSpawn); yield break; } + + /// + /// Stands the spawner back down without placing anything. Deliberately leaves both + /// placed and spawnPoint untouched: Update's searchingForSpawn gate then keeps + /// the component idle, the persistent ZDO survives, and the search runs again on the next + /// visit (or after the retry delay for a player who stays in the zone). Marking it placed + /// here would destroy a bounty or treasure map the player has already paid for. + /// + private void ParkAndRetry() + { + startedPlacement = false; + currentUpdates = 0; + updatesRequired = RetrySearchDelayFrames; + } + + /// + /// True when the chosen point falls outside the circle drawn on the map. The pin's + /// m_worldSize is a diameter (vanilla sets it to range * 2), and MinimapController + /// assigns MinimapAreaRadius * AreaScale, so the drawn radius is half of that. + /// + private static bool RequiresPinRelocation(Vector3 pinCentre, Vector3 spawn) + { + float drawnRadius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius * + MinimapController.AreaScale * 0.5f; + return Utils.DistanceXZ(pinCentre, spawn) > drawnRadius; + } + + /// + /// Moves the owning player's minimap pin to . + /// Returns false only when the local player is not the one who bought this spawn - a missing + /// or already-resolved save record still counts as handled, since parking forever would be + /// worse than a stale pin. + /// + private bool TryRelocateOwnerPin(Vector3 newPosition) + { + Player player = Player.m_localPlayer; + if (player == null) + { + return false; + } + + long localPlayerID = player.GetPlayerID(); + AdventureSaveData saveData = player.GetAdventureSaveData(); + + bool relocated; + string description; + + if (isBounty.Get() == true) + { + BountyInfo bountyInfo = bounty.Get(); + if (bountyInfo.PlayerID != localPlayerID) + { + return false; + } + + relocated = saveData != null && saveData.RelocateBounty(bountyInfo.ID, newPosition); + description = $"bountyID={bountyInfo.ID}"; + } + else + { + TreasureMapChestInfo treasureInfo = treasure.Get(); + if (treasureInfo.PlayerID != localPlayerID) + { + return false; + } + + relocated = saveData != null && + saveData.RelocateTreasureMap(treasureInfo.Interval, treasureInfo.Biome, newPosition); + description = $"interval={treasureInfo.Interval} biome={treasureInfo.Biome}"; + } + + if (relocated) + { + player.Message(MessageHud.MessageType.Center, "$mod_epicloot_adventure_spawnrelocated"); + } + else + { + EpicLoot.LogWarning("Moved an adventure spawn outside its map circle but could not " + + $"update the minimap pin ({description})."); + } + + return true; + } } } diff --git a/EpicLoot/src/Adventure/feature/Bounties.cs b/EpicLoot/src/Adventure/feature/Bounties.cs index 06acab307..9831ce2d9 100755 --- a/EpicLoot/src/Adventure/feature/Bounties.cs +++ b/EpicLoot/src/Adventure/feature/Bounties.cs @@ -330,6 +330,8 @@ public void ClaimBountyReward(BountyInfo bountyInfo) { InventoryManagement.Instance.GiveItem("Coins", bountyInfo.RewardCoins); } + + API.RaiseBountyCompleted(player, bountyInfo.Target?.MonsterID); } public void AbandonBounty(BountyInfo bountyInfo) diff --git a/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs b/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs index 39333ee40..8889808b8 100755 --- a/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs +++ b/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs @@ -252,9 +252,8 @@ internal static bool IsSpawnLocationValid(Vector3 location, out Heightmap.Biome } // Is too near to player ward - var tooCloseToWard = PrivateArea.m_allAreas.Any( - x => x.IsInside(location, AdventureDataManager.Config.TreasureMap.MinimapAreaRadius)); - if (tooCloseToWard) + if (AdventureWardCheck.TryFindNearbyWard(location, + AdventureDataManager.Config.TreasureMap.MinimapAreaRadius, out _)) { return false; } diff --git a/EpicLoot/src/Compendium/ExplainTextInfo.cs b/EpicLoot/src/Compendium/ExplainTextInfo.cs index 669946ebf..c5cf31515 100644 --- a/EpicLoot/src/Compendium/ExplainTextInfo.cs +++ b/EpicLoot/src/Compendium/ExplainTextInfo.cs @@ -9,9 +9,9 @@ public override void Build(MagicPages instance) { IOrderedEnumerable> sortedMagicEffects = MagicItemEffectDefinitions.AllDefinitions .Where(x => !x.Value.Requirements.NoRoll && x.Value.CanBeAugmented) - .Select(x => new KeyValuePair(string.Format(Localization.instance.Localize(x.Value.DisplayText), - "X"), - Localization.instance.Localize(x.Value.Description))) + .Select(x => new KeyValuePair( + MagicItem.GetEffectTextGeneric(x.Value, "X"), + MagicItem.GetEffectDescriptionGeneric(x.Value, "X"))) .OrderBy(x => x.Key); foreach (KeyValuePair kvp in sortedMagicEffects) diff --git a/EpicLoot/src/Compendium/MagicFontManager.cs b/EpicLoot/src/Compendium/MagicFontManager.cs index ff4d4bf67..a7aa482f1 100644 --- a/EpicLoot/src/Compendium/MagicFontManager.cs +++ b/EpicLoot/src/Compendium/MagicFontManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using TMPro; using UnityEngine; namespace EpicLoot.Compendium; @@ -17,12 +18,31 @@ public enum FontOptions [InternalName("LegacyRuntime")] LegacyRuntime } + public enum TMP_FontOptions + { + [TMP_Attributes("Valheim-Norse", "Valheim-Norse - Outline")] Norse, + [TMP_Attributes("Valheim-Norsebold", "Valheim-Norsebold - Outline")] NorseBoldOutline, + [TMP_Attributes("Valheim-AveriaSansLibre", "Valheim-AveriaSansLibre")] AveriaSansLibre, + [TMP_Attributes("Valheim-AveriaSansLibre", "Valheim-AveriaSansLibre - Outline")] AveriaSansLibreOutline, + [TMP_Attributes("AveriaSansLibre-Bold SDF", "Valheim-AveriaSansLibre - Outline")] AveriaSansLibreBoldOutline, + [TMP_Attributes("Valheim-AveriaSerifLibre", "Valheim-AveriaSerifLibre - Outline")] AveriaSerifLibreOutline, + [TMP_Attributes("Valheim-Rune", "Valheim-Rune")] Rune, + + } + private class InternalName(string internalName) : Attribute { public readonly string internalName = internalName; } + private class TMP_Attributes(string fontName, string materialName) : Attribute + { + public readonly string fontName = fontName; + public readonly string materialName = materialName; + } + private static readonly Dictionary m_fonts = new(); + private static readonly Dictionary m_fontAssets = new(); public static Font GetFont(FontOptions option) { @@ -36,4 +56,92 @@ public static Font GetFont(FontOptions option) m_fonts[option] = match; return match; } -} \ No newline at end of file + + public static TMP_FontData GetTMPFont(TMP_FontOptions option) + { + if (m_fontAssets.TryGetValue(option, out TMP_FontData asset)) return asset; + + TMP_Attributes attributes = option.GetAttributeOfType(); + TMP_FontAsset[] assets = Resources.FindObjectsOfTypeAll(); + Material[] materials = Resources.FindObjectsOfTypeAll(); + + TMP_FontAsset matchFont = assets.FirstOrDefault(x => x.name == attributes.fontName); + Material matchMaterial = materials.FirstOrDefault(x => x.name == attributes.materialName); + TMP_FontData data = new TMP_FontData { font = matchFont, material = matchMaterial}; + m_fontAssets[option] = data; + return data; + } + + public class TMP_FontData + { + public TMP_FontAsset font; + public Material material; + } +} + +// ################## TMP Fonts: +// Opensans - Fallback +// AveriaSansLibre-Bold SDF +// NotoSansJP-Regular SDF +// NotoSansSC-Regular SDF +// Valheim-AveriaSansLibre +// NotoSansThai-Regular SDF +// Valheim-AveriaSerifLibre +// NotoSerifJP-Regular SDF +// NotoSerifArmenian-Regular SDF +// NotoSerifDevanagari-Regular SDF +// NotoSerifGeorgian-Regular SDF +// NotoSerifThai-Regular SDF +// NotoEmoji-Regular SDF +// NotoSerifMalayalam-Regular SDF +// NotoSerifBengali-Regular SDF +// NotoSansHebrew-Regular SDF +// NotoSansArabic-Regular SDF +// Fallback-NotoSerifNormal +// NotoSerifSC-Regular SDF +// NotoSerifKR-Regular SDF +// NotoSansBengali-Regular SDF +// Valheim-Prstartk +// NotoSansGeorgian-Regular SDF +// NotoSansArmenian-Regular SDF +// Fallback-NotoSansNormal +// NotoSansKR-Regular SDF +// NotoSansMalayalam-Regular SDF +// NotoSansDevanagari-Regular SDF +// Valheim-Norsebold +// NotoSansSC-Thin SDF +// NotoSansJP-Thin SDF +// NotoSansDevanagari-ExtraLight SDF +// NotoSansThai-ExtraLight SDF +// NotoSansBengali-ExtraLight SDF +// Valheim-Norse +// NotoSansArmenian-ExtraLight SDF +// NotoSansKR-Thin SDF +// NotoSansHebrew-Light SDF +// NotoEmoji-Light SDF +// NotoSansGeorgian-ExtraLight SDF +// NotoSansArabic-Light SDF +// NotoSansMalayalam-ExtraLight SDF +// Fallback-NotoSansThin +// Valheim-Rune + + +// ################## Materials: +// Valheim-AveriaSansLibre - Outline +// Valheim-AveriaSerifLibre - Outline +// Valheim-Prstartk +// Valheim-AveriaSansLibre +// Valheim-Prstartk - Outline +// Valheim-AveriaSerifLibre +// Valheim-Norsebold +// Valheim-Norsebold - Outline (Thin) +// Valheim-Norsebold - Outline +// Valheim-AveriaSerifLibre - Outline (Thin) +// Valheim-Norse +// Valheim-Norse - Sign Lit +// Valheim-Rune +// Valheim-Prstartk - Outline (Thin) +// Valheim-Norse - Outline +// Valheim-Norse - Outline (Thin) +// Valheim-AveriaSansLibre - Outline (Thin) +// Valheim-AveriaSansLibre - Outline (Thick) \ No newline at end of file diff --git a/EpicLoot/src/Compendium/SetInfo.cs b/EpicLoot/src/Compendium/SetInfo.cs index 03c729cee..ff35f1c1b 100644 --- a/EpicLoot/src/Compendium/SetInfo.cs +++ b/EpicLoot/src/Compendium/SetInfo.cs @@ -45,9 +45,8 @@ private static void FormatSetInfo(MagicPages instance, LegendarySetInfo set, Ite continue; } - content.Add($" - ({bonus.Count}) " + - $"{string.Format(Localization.instance.Localize(definition.DisplayText), - "X")}"); + content.Add($" - ({bonus.Count}) " + + $"{MagicItem.GetEffectTextGeneric(definition, "X")}"); } instance.MagicPagesTextArea.Add($"" + diff --git a/EpicLoot/src/Config/ConfigVersionManager.cs b/EpicLoot/src/Config/ConfigVersionManager.cs new file mode 100644 index 000000000..fc5fb5981 --- /dev/null +++ b/EpicLoot/src/Config/ConfigVersionManager.cs @@ -0,0 +1,381 @@ +using BepInEx; +using EpicLoot.Patching; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace EpicLoot.Config; + +/// +/// Keeps the baseconfig files in step with the version of the mod that is running. +/// +/// Background: a baseconfig file with no patches targeting it is only ever written when it is +/// missing (see FilePatching.LoadPatchedJSON). A player who installed an older version therefore +/// keeps that version's loot tables, effects and balance forever, with nothing to indicate it. +/// Files that *do* have patches are rebuilt from the current embedded default every launch and are +/// never out of date. +/// +/// Two fingerprints are recorded per file, and both are needed: +/// SourceHash - the embedded default that produced the file. Answers "is there anything new to apply?" +/// WrittenHash - the content the mod actually wrote. Answers "is it safe to apply it?" +/// A file still matching its WrittenHash has not been touched by the player, so it is refreshed +/// silently. Anything else is the player's own work and is only ever changed if they say so. +/// +public static class ConfigVersionManager +{ + /// Player-modified configs whose shipped default has changed. These drive the prompt. + public static readonly List OutdatedConfigs = new List(); + + private static ConfigVersionState _state; + private static readonly Dictionary _pendingSourceHashes = new Dictionary(); + private static readonly List _stampAfterInit = new List(); + private static bool _enabled; + private static bool _detectionRan; + + public static bool HasOutdatedConfigs => OutdatedConfigs.Count > 0; + + /// Guards the prompt against running before detection has had a chance to populate. + public static bool DetectionRan => _detectionRan; + + /// + /// Snapshotted during Awake because both this feature and WelcomeMessage postfix + /// FejdStartup.Start, and Harmony does not guarantee which of the two runs first. Reading the + /// config entry from the prompt itself would race with WelcomeMessage clearing it. + /// + public static bool WelcomeMessageWillShow { get; private set; } + + // A sibling of baseconfig/, not a child, so the directory the player browses stays clean. + private static string BackupDirPath => Path.Combine(Paths.ConfigPath, "EpicLoot", "baseconfig-backup"); + + /// + /// Refreshes every baseconfig file the player has not edited, and collects the ones they have. + /// + /// Must run *before* ELConfig.InitializeConfig so the refreshed contents are what gets + /// deserialized. Writing afterwards would rely on the per-file FileSystemWatcher firing part way + /// through Awake, which is both asynchronous and unnecessary. + /// + public static void RefreshUnmodifiedConfigs() + { + OutdatedConfigs.Clear(); + _pendingSourceHashes.Clear(); + _stampAfterInit.Clear(); + + WelcomeMessageWillShow = ELConfig.AlwaysShowWelcomeMessage.Value; + _enabled = !ELConfig.AlwaysRefreshCoreConfigs.Value; + + if (!_enabled) + { + // The player has opted into unconditional overwrite, so nothing can ever be out of date. + EpicLoot.Log("'Always Refresh Core Configs' is enabled, skipping config version checks."); + return; + } + + _state = ConfigVersionState.Load(); + + string baseConfigDir; + try + { + baseConfigDir = ELConfig.GetOverhaulDirectoryPath(); + } + catch (Exception e) + { + EpicLoot.LogWarningForce($"Could not open the baseconfig directory, " + + $"skipping config version checks.\n{e.Message}"); + _enabled = false; + return; + } + + bool stateChanged = false; + + foreach (string name in FilePatching.ConfigFileNames) + { + string embeddedHash = GetEmbeddedHash(name, out string variant, out string embedded); + if (string.IsNullOrEmpty(embeddedHash)) + { + // Could not read the embedded default (see GetEmbeddedHash). Nothing meaningful to + // compare against, so leave any existing stamp alone rather than guessing. + continue; + } + + string configPath = Path.Combine(baseConfigDir, $"{name}.json"); + + // Missing files are written by InitializeConfig, and patched files are rebuilt from the + // current embedded default every launch. Both are current by construction, but neither + // exists in its final form yet, so stamp them once InitializeConfig has run. + if (!File.Exists(configPath) || FilePatching.PatchesPerFile.GetValues(name, true).Count > 0) + { + _stampAfterInit.Add(name); + continue; + } + + ConfigVersionEntry entry = _state.Get(name); + + if (entry != null && entry.SourceHash == embeddedHash && entry.Variant == (variant ?? "")) + { + // The shipped default has not changed since this file was written, so there is + // nothing to apply no matter what the player has done to it. + if (entry.Version != EpicLoot.Version) + { + _state.TouchVersion(name); + stateChanged = true; + } + + continue; + } + + string diskHash = TryHashFile(configPath); + if (string.IsNullOrEmpty(diskHash)) + { + continue; + } + + if (diskHash == embeddedHash) + { + // Already identical to the new default; only the bookkeeping was behind. + _state.Stamp(name, embeddedHash, diskHash, variant); + stateChanged = true; + continue; + } + + if (entry != null && diskHash == entry.WrittenHash) + { + // Untouched since we wrote it, so replacing it cannot lose any of the player's work. + if (TryWriteDefault(name, configPath, embedded)) + { + _state.Stamp(name, embeddedHash, embeddedHash, variant); + stateChanged = true; + EpicLoot.Log($"Refreshed unmodified config {name}.json to version {EpicLoot.Version}."); + } + + continue; + } + + // Either the player edited it, or it predates version tracking and its origin is unknown. + // Either way it is theirs; only offer, never take. + if (entry != null && entry.DeclinedSourceHash == embeddedHash) + { + EpicLoot.Log($"Config {name}.json differs from the current default, " + + $"but an update was already declined for this version."); + continue; + } + + EpicLoot.Log($"Config {name}.json was written by version " + + $"{(entry == null || string.IsNullOrEmpty(entry.Version) ? "" : entry.Version)}, " + + $"has local changes, and its shipped default has changed since."); + OutdatedConfigs.Add(name); + _pendingSourceHashes[name] = embeddedHash; + } + + if (stateChanged) + { + _state.Save(); + } + } + + /// + /// Stamps the files that InitializeConfig created or rebuilt, then reports anything still + /// awaiting the player's decision. Must run after ELConfig.InitializeConfig. + /// + public static void StampInitializedConfigs() + { + _detectionRan = true; + + if (!_enabled || _state == null) + { + return; + } + + bool stateChanged = false; + string baseConfigDir = ELConfig.GetOverhaulDirectoryPath(); + + foreach (string name in _stampAfterInit) + { + string embeddedHash = GetEmbeddedHash(name, out string variant, out _); + if (string.IsNullOrEmpty(embeddedHash)) + { + continue; + } + + // Read back what was actually written: for a patched file that is the embedded default + // plus its patches, which is what a later launch will need to compare against. + string writtenHash = TryHashFile(Path.Combine(baseConfigDir, $"{name}.json")); + if (string.IsNullOrEmpty(writtenHash)) + { + continue; + } + + _state.Stamp(name, embeddedHash, writtenHash, variant); + stateChanged = true; + } + + if (stateChanged) + { + _state.Save(); + } + + if (HasOutdatedConfigs) + { + EpicLoot.LogWarningForce($"{OutdatedConfigs.Count} Epic Loot config file(s) have local changes and " + + $"were not written by version {EpicLoot.Version}: {string.Join(", ", OutdatedConfigs)}. " + + $"They will not pick up new content or balance changes until they are updated."); + } + } + + /// + /// Records content the mod itself wrote to a baseconfig file, so that its own output is not + /// mistaken for a player edit. Callers must pass exactly what they wrote to disk. + /// + public static void RecordWrittenContent(string configName, string contents) + { + if (!_enabled || _state == null) + { + return; + } + + try + { + string embeddedHash = GetEmbeddedHash(configName, out string variant, out _); + if (string.IsNullOrEmpty(embeddedHash)) + { + return; + } + + _state.Stamp(configName, embeddedHash, ConfigVersionState.HashConfigText(contents), variant); + _state.Save(); + } + catch (Exception e) + { + EpicLoot.LogWarning($"Could not record written content for {configName}.json.\n{e.Message}"); + } + } + + /// + /// Remembers that the player declined, per file, against the default they declined for. A later + /// release that changes that default will ask again; nothing else is suppressed. + /// + public static void DeclineOutdatedConfigs() + { + if (_state == null) + { + return; + } + + foreach (string name in OutdatedConfigs) + { + _state.RecordDecline(name, _pendingSourceHashes.TryGetValue(name, out string hash) ? hash : ""); + } + + _state.Save(); + EpicLoot.LogForce($"Epic Loot config update declined for {OutdatedConfigs.Count} file(s); " + + $"you will be asked again if their defaults change in a future update."); + OutdatedConfigs.Clear(); + } + + /// + /// Backs up every outdated config to a timestamped folder, then rewrites it from the embedded + /// default. Writing each file trips its FileSystemWatcher, so the in-memory config reloads + /// without a restart (the same mechanism FilePatching.LoadPatchedJSON relies on). + /// + public static void BackupAndResetOutdatedConfigs() + { + if (_state == null || !HasOutdatedConfigs) + { + return; + } + + // Named for the version being upgraded *to*: the files inside predate it, and they may not + // all have come from the same older version, so naming it after one of them would mislead. + string backupDir = Path.Combine(BackupDirPath, $"pre-{EpicLoot.Version}_{DateTime.Now:yyyyMMdd-HHmmss}"); + string baseConfigDir = ELConfig.GetOverhaulDirectoryPath(); + List updated = new List(); + + foreach (string name in OutdatedConfigs.ToList()) + { + string fileName = $"{name}.json"; + string configPath = Path.Combine(baseConfigDir, fileName); + + try + { + if (File.Exists(configPath)) + { + Directory.CreateDirectory(backupDir); + File.Copy(configPath, Path.Combine(backupDir, fileName), true); + } + + string embeddedHash = GetEmbeddedHash(name, out string variant, out string embedded); + ELConfig.CreateBaseConfigurations(configPath, fileName); + _state.Stamp(name, embeddedHash, embeddedHash, variant); + updated.Add(name); + } + catch (Exception e) + { + // Leave this file marked outdated so the player is asked again next launch. + EpicLoot.LogErrorForce($"Failed to update config {fileName}, it has been left unchanged.\n{e}"); + } + } + + foreach (string name in updated) + { + OutdatedConfigs.Remove(name); + } + + _state.Save(); + EpicLoot.LogForce($"Updated {updated.Count} Epic Loot config file(s) to version {EpicLoot.Version}. " + + $"The previous files were backed up to {backupDir}"); + } + + /// Writes the embedded default over a config, reporting failure rather than throwing. + private static bool TryWriteDefault(string configName, string configPath, string embedded) + { + try + { + File.WriteAllText(configPath, embedded); + return true; + } + catch (Exception e) + { + EpicLoot.LogWarningForce($"Could not refresh {configName}.json, it has been left unchanged.\n{e.Message}"); + return false; + } + } + + /// Hashes a config on disk, returning empty on any read failure so callers can skip it. + private static string TryHashFile(string path) + { + try + { + return ConfigVersionState.HashConfigText(File.ReadAllText(path)); + } + catch (Exception e) + { + EpicLoot.LogWarning($"Could not read {path} for a config version check.\n{e.Message}"); + return ""; + } + } + + /// + /// Fingerprints the embedded default for a config, also handing back its text so callers can + /// write it without a second read. Returns an empty string when the resource cannot be read: + /// ReadEmbeddedResourceFile throws on a missing resource, and this runs inside ELConfig's + /// constructor during Awake, where an escaping exception would take down the mod. + /// + private static string GetEmbeddedHash(string configName, out string variant, out string embedded) + { + variant = configName == "magiceffects" ? ELConfig.BalanceConfigurationType.Value : ""; + embedded = null; + + try + { + embedded = EpicLoot.ReadEmbeddedResourceFile( + ELConfig.GetDefaultEmbeddedFileLocation($"{configName}.json")); + return ConfigVersionState.HashConfigText(embedded); + } + catch (Exception e) + { + EpicLoot.LogWarning($"Could not read the embedded default for {configName}.json, " + + $"skipping its version check.\n{e.Message}"); + return ""; + } + } +} diff --git a/EpicLoot/src/Config/ConfigVersionState.cs b/EpicLoot/src/Config/ConfigVersionState.cs new file mode 100644 index 000000000..df7f84168 --- /dev/null +++ b/EpicLoot/src/Config/ConfigVersionState.cs @@ -0,0 +1,168 @@ +using BepInEx; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace EpicLoot.Config; + +/// +/// Records which mod version last wrote each baseconfig file, and a hash of the embedded default +/// that produced it. Used to detect base configs left behind by an older version of the mod. +/// +[Serializable] +public class ConfigVersionEntry +{ + /// The EpicLoot.Version that last wrote or validated this file. + public string Version = ""; + + /// + /// SHA-256 of the *embedded default* that was current when this entry was stamped. This is not a + /// copy of the config, just a fingerprint, and it is deliberately taken from the embedded resource + /// rather than the file on disk: iteminfo/loottables/adventuredata are rewritten at runtime by + /// AutoAddEnchantableItems, so on-disk content always diverges and would be useless to compare. + /// + public string SourceHash = ""; + + /// + /// Hash of the exact content the mod last wrote to this file. When the file on disk still hashes + /// to this, nobody has edited our output and it is safe to refresh silently. Without it we could + /// not tell "the player changed this" apart from "this file is simply old". + /// + public string WrittenHash = ""; + + /// Only meaningful for magiceffects, whose embedded source depends on BalanceConfigurationType. + public string Variant = ""; + + /// + /// The SourceHash the player declined an update for. The prompt stays quiet while it still + /// matches, so a later release that changes this config's default moves the hash and asks again. + /// + public string DeclinedSourceHash = ""; + + /// Recorded alongside DeclinedSourceHash purely so the file reads sensibly by hand. + public string DeclinedVersion = ""; +} + +[Serializable] +public class ConfigVersionState +{ + public Dictionary Files = new Dictionary(); + + // Lives outside baseconfig/ so it can never be confused for a config the player should edit, and + // so it can never trip the per-file FileSystemWatchers registered in ELConfig.SychronizeConfig. + public static string FilePath => Path.Combine(Paths.ConfigPath, "EpicLoot", "configstate.json"); + + public static ConfigVersionState Load() + { + try + { + if (File.Exists(FilePath)) + { + ConfigVersionState loaded = + JsonConvert.DeserializeObject(File.ReadAllText(FilePath)); + if (loaded != null) + { + loaded.Files ??= new Dictionary(); + return loaded; + } + } + } + catch (Exception e) + { + // A corrupt state file must never block startup; treat it as absent. Every config then + // reads as "unknown origin", which surfaces the update prompt rather than hiding a problem. + EpicLoot.LogWarningForce($"Could not read {FilePath}, treating config versions as unknown.\n{e.Message}"); + } + + return new ConfigVersionState(); + } + + public void Save() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)); + File.WriteAllText(FilePath, JsonConvert.SerializeObject(this, Formatting.Indented)); + } + catch (Exception e) + { + EpicLoot.LogWarningForce($"Could not write {FilePath}! Config version tracking will not persist.\n{e.Message}"); + } + } + + public ConfigVersionEntry Get(string configName) + { + return Files.TryGetValue(configName, out ConfigVersionEntry entry) ? entry : null; + } + + /// + /// Records a file as being exactly the mod's own output. Any previous decline is dropped: the + /// player's edits are no longer present, so there is nothing left to protect. + /// + public void Stamp(string configName, string sourceHash, string writtenHash, string variant) + { + Files[configName] = new ConfigVersionEntry + { + Version = EpicLoot.Version, + SourceHash = sourceHash, + WrittenHash = writtenHash ?? "", + Variant = variant ?? "" + }; + } + + /// + /// Moves an entry to the current mod version without otherwise disturbing it. Used when the + /// shipped default has not changed, where the file is still valid and any decline still applies. + /// + public void TouchVersion(string configName) + { + if (Files.TryGetValue(configName, out ConfigVersionEntry entry)) + { + entry.Version = EpicLoot.Version; + } + } + + public void RecordDecline(string configName, string sourceHash) + { + if (!Files.TryGetValue(configName, out ConfigVersionEntry entry)) + { + entry = new ConfigVersionEntry(); + Files[configName] = entry; + } + + entry.DeclinedSourceHash = sourceHash ?? ""; + entry.DeclinedVersion = EpicLoot.Version; + } + + /// + /// Hashes text rather than bytes: the shipped configs have inconsistent UTF-8 BOMs and line + /// endings, which would otherwise produce spurious mismatches between platforms and checkouts. + /// A false mismatch here would make an untouched config look edited and prompt forever, so the + /// BOM is stripped explicitly rather than trusted to the caller's reader - Trim() will not + /// remove U+FEFF, which .NET does not classify as whitespace. + /// + public static string HashConfigText(string text) + { + if (text == null) + { + return ""; + } + + string normalized = text.Replace("\uFEFF", "").Replace("\r\n", "\n").Replace("\r", "\n").Trim(); + + using (SHA256 sha = SHA256.Create()) + { + byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(normalized)); + StringBuilder sb = new StringBuilder(hash.Length * 2); + foreach (byte b in hash) + { + sb.Append(b.ToString("x2")); + } + + return sb.ToString(); + } + } +} diff --git a/EpicLoot/src/Config/ELConfig.cs b/EpicLoot/src/Config/ELConfig.cs index aa281dc7e..e2caf1edc 100755 --- a/EpicLoot/src/Config/ELConfig.cs +++ b/EpicLoot/src/Config/ELConfig.cs @@ -9,6 +9,7 @@ using EpicLoot.LegendarySystem; using EpicLoot.Magic; using EpicLoot.Patching; +using EpicLoot.ShardStones; using EpicLoot_UnityLib; using Jotunn.Entities; using Jotunn.Managers; @@ -24,8 +25,7 @@ namespace EpicLoot.Config; -internal class ELConfig -{ +internal class ELConfig { public static ConfigFile cfg; public static ConfigEntry _setItemColor; @@ -40,10 +40,13 @@ internal class ELConfig public static ConfigEntry _legendaryMaterialIconColor; public static ConfigEntry _mythicMaterialIconColor; public static ConfigEntry UseScrollingCraftDescription; + public static ConfigEntry ShowRarityInRecipeList; + public static ConfigEntry ShowEnchantSelectionChance; public static ConfigEntry TransferMagicItemToCrafts; public static ConfigEntry _loggingEnabled; public static ConfigEntry _logLevel; public static ConfigEntry UseGeneratedMagicItemNames; + public static ConfigEntry KeepInventoryOpenOverItems; public static ConfigEntry _gatedItemTypeModeConfig; public static ConfigEntry BossBountyMode; public static ConfigEntry GatedFreebuildMode; @@ -58,8 +61,19 @@ internal class ELConfig public static ConfigEntry AbilityBarLayoutAlignment; public static ConfigEntry AbilityBarIconSpacing; public static ConfigEntry SetItemDropChance; + public static ConfigEntry AllowDuplicateSocketedEffects; + public static ConfigEntry AllowShardstoneDuplicateItemEffect; + public static ConfigEntry AllowRunestoneDuplicateItemEffect; + // Named ...RemovalMode rather than ...SocketMode: a static field sharing its type's name would + // shadow the enum inside this class and break `ShardSocketMode.Free` in the bind call below. + public static ConfigEntry ShardSocketRemovalMode; + public static ConfigEntry RuneSocketRemovalMode; + // Named ...StackingMode for the same shadowing reason as ShardSocketRemovalMode above. + public static ConfigEntry ShardStackingMode; + public static ConfigEntry ShardStackDecayFactor; public static ConfigEntry GlobalDropRateModifier; - public static ConfigEntry ItemsToMaterialsDropRatio; + public static ConfigEntry DeferChestLootRoll; + public static ConfigEntry AlwaysShowWelcomeMessage; public static ConfigEntry OutputPatchedConfigFiles; public static ConfigEntry EnchantingTableUpgradesActive; @@ -74,19 +88,32 @@ internal class ELConfig public static ConfigEntry AutoAddEquipment; public static ConfigEntry AutoRemoveEquipmentNotFound; public static ConfigEntry OnlyAddEquipmentWithRecipes; + public static ConfigEntry ItemDropRatio; + public static ConfigEntry MaterialsDropRatio; public static ConfigEntry ItemsUnidentifiedDropRatio; + public static ConfigEntry ShardStoneDropRatio; public static ConfigEntry UIAudioVolumeAdjustment; public static ConfigEntry AutoAddRemoveEquipmentFromVendor; public static ConfigEntry AutoAddRemoveEquipmentFromLootLists; public static ConfigEntry EnableHotReloadPatches; public static ConfigEntry AlwaysRefreshCoreConfigs; + public static ConfigEntry TooltipMaxWidth; + public static ConfigEntry TooltipMaxHeight; + public static ConfigEntry TraderPanelPositionX; + public static ConfigEntry TraderPanelPositionY; + public static ConfigEntry TemperPanelPositionX; + public static ConfigEntry TemperPanelPositionY; + + public static ConfigEntry RuneExtractItemMode; - public static ConfigEntry RuneExtractDestroysItem; + public static ConfigEntry TemperDestroysItem; + public static ConfigEntry TemperChanceToDestroy; + public static ConfigEntry TemperBaseChance; + public static ConfigEntry TemperDecrement; private static CustomRPC LootTablesRPC; private static CustomRPC MagicEffectsRPC; private static CustomRPC ItemConfigRPC; - private static CustomRPC RecipesRPC; private static CustomRPC EnchantingCostsRPC; private static CustomRPC ItemNamesRPC; private static CustomRPC AdventureDataRPC; @@ -95,6 +122,8 @@ internal class ELConfig private static CustomRPC MaterialConversionRPC; private static CustomRPC EnchantingUpgradesRPC; private static CustomRPC AutoSorterConfigurationRPC; + private static CustomRPC ShardStonesRPC; + private static CustomRPC ShardStoneConversionsRPC; private static string LocalizationDir = GetLocalizationDirectoryPath(); private static readonly List LocalizationLanguages = new List() { @@ -136,28 +165,96 @@ internal class ELConfig "Latvian" }; - public ELConfig(ConfigFile Config) - { + // Sections, in the order they are bound and therefore the order the player sees them. The ordered + // binder numbers each section as it is first bound ("2 - Balance"), and that number is part of the + // name on disk, so: + // - there can be at most nine sections; a tenth sorts as "10 - " between "1 - " and "2 - ". + // - moving a section renames it, which orphans the player's values. RelocatedSettings below carries + // them across, and StripOrderPrefix makes a pure renumbering (a section inserted in the middle) + // survive on its own. + private const string SectionGeneral = "General"; + private const string SectionBalance = "Balance"; + private const string SectionSockets = "Shardstones & Runes"; + private const string SectionEnchanting = "Enchanting Table"; + private const string SectionAdventure = "Adventure"; + private const string SectionInterface = "Interface"; + private const string SectionItemColors = "Item Colors"; + private const string SectionAbilities = "Abilities"; + private const string SectionDebug = "Debug"; + + // Migration dictionary for historic settings + // TODO: Delete after a few releases, once the player base has had time to migrate their configs. + private static readonly Dictionary RelocatedSettings = new Dictionary { + // Rune extraction is a socket operation, not a drop-rate one. + { $"{SectionSockets}::Rune Extract Mode", "Balance::Rune Extract Mode" }, + // Tempering folded into the enchanting table section; its keys were only unambiguous while they + // had a section of their own. + { $"{SectionEnchanting}::Temper Base Chance", "Tempering::Base Chance" }, + { $"{SectionEnchanting}::Temper Decrement Amount", "Tempering::Decrement Amount" }, + { $"{SectionEnchanting}::Temper Fail Destroys Item", "Tempering::Fail Destroys Item" }, + { $"{SectionEnchanting}::Temper Destroy Chance", "Tempering::Destroy Chance" }, + // Adventure mode gathered out of Balance and the old Bounty Management section. + { $"{SectionAdventure}::Adventure Mode Enabled", "Balance::Adventure Mode Enabled" }, + { $"{SectionAdventure}::Andvaranaut Range", "Balance::Andvaranaut Range" }, + { $"{SectionAdventure}::Gated Bounty Mode", "Balance::Gated Bounty Mode" }, + { $"{SectionAdventure}::Enable Bounty Limit", "Bounty Management::Enable Bounty Limit" }, + { $"{SectionAdventure}::Max Bounties Per Player", "Bounty Management::Max Bounties Per Player" }, + // Interface absorbed Crafting UI, Tooltips and the panel positions that sat under General. + { $"{SectionInterface}::Use Scrolling Craft Description", "Crafting UI::Use Scrolling Craft Description" }, + { $"{SectionInterface}::Show Enchant Selection Chance", "Crafting UI::Show Enchant Selection Chance" }, + { $"{SectionInterface}::ShowEquippedAndHotbarItemsInSacrificeTab", "Crafting UI::ShowEquippedAndHotbarItemsInSacrificeTab" }, + { $"{SectionInterface}::AudioVolumeAdjustment", "Crafting UI::AudioVolumeAdjustment" }, + { $"{SectionInterface}::Tooltip Max Width", "Tooltips::Max Width" }, + { $"{SectionInterface}::Tooltip Max Height", "Tooltips::Max Height" }, + { $"{SectionInterface}::Trader Panel X Position", "General::Trader Panel X Position" }, + { $"{SectionInterface}::Trader Panel Y Position", "General::Trader Panel Y Position" }, + { $"{SectionInterface}::Temper Panel X Position", "General::Temper Panel X Position" }, + { $"{SectionInterface}::Temper Panel Y Position", "General::Temper Panel Y Position" }, + // Logging is diagnostics; it belongs with the rest of them. + { $"{SectionDebug}::Logging Enabled", "Logging::Logging Enabled" }, + { $"{SectionDebug}::Log Level", "Logging::Log Level" } + }; + + /// Raw values of the config file as it was before this run bound anything, keyed "Section::Key". + private static readonly Dictionary PreviousConfigValues = new Dictionary(); + + /// Everything bound this run, with the section name minus its order prefix. + private static readonly List<(ConfigEntryBase Entry, string Location)> BoundEntries = + new List<(ConfigEntryBase, string)>(); + + public ELConfig(ConfigFile Config) { // ensure all the config values are created cfg = Config; + // Bind first, save once. Every Set with SaveOnConfigSet on rewrites the whole file, and the + // migration below sets a good fraction of the entries. + cfg.SaveOnConfigSet = false; + ReadPreviousConfigValues(); + CreateConfigValues(); + ApplyPreviousConfigValues(); + // After the migration, so restoring a saved value does not fire a handler into a UI that Awake + // has not built yet. + RegisterSettingChangedHandlers(); cfg.SaveOnConfigSet = true; - CreateConfigValues(Config); + cfg.Save(); SetupConfigRPCs(); FilePatching.LoadAllPatches(); + // Bring any config the player has not edited up to date first, so InitializeConfig reads the + // refreshed contents rather than relying on a file watcher to fire part way through Awake. + ConfigVersionManager.RefreshUnmodifiedConfigs(); + // InitializeConfig applies patches per file via SychronizeConfig -> LoadPatchedJSON, so a separate + // ApplyAllPatches() pass here would be redundant (and cause extra file-watcher reloads). InitializeConfig(); - FilePatching.ApplyAllPatches(); + ConfigVersionManager.StampInitializedConfigs(); + FilePatching.LogAppliedPatchSummary(); } - public void SetupConfigRPCs() - { + public void SetupConfigRPCs() { LootTablesRPC = NetworkManager.Instance.AddRPC("epicloot_loottables_RPC", OnServerRecieveConfigs, OnClientRecieveLootConfigs); MagicEffectsRPC = NetworkManager.Instance.AddRPC("epicloot_magiceffect_RPC", OnServerRecieveConfigs, OnClientRecieveMagicConfigs); ItemConfigRPC = NetworkManager.Instance.AddRPC("epicloot_itemconfig_RPC", OnServerRecieveConfigs, OnClientRecieveItemInfoConfigs); - RecipesRPC = NetworkManager.Instance.AddRPC("epicloot_recipes_RPC", - OnServerRecieveConfigs, OnClientRecieveRecipesConfigs); EnchantingCostsRPC = NetworkManager.Instance.AddRPC("epicloot_enchantingcosts_RPC", OnServerRecieveConfigs, OnClientRecieveEnchantingCostsConfigs); ItemNamesRPC = NetworkManager.Instance.AddRPC("ItemNamesRPC", @@ -172,94 +269,44 @@ public void SetupConfigRPCs() OnServerRecieveConfigs, OnClientRecieveMaterialConversionConfigs); EnchantingUpgradesRPC = NetworkManager.Instance.AddRPC("EnchantingUpgradesRPC", OnServerRecieveConfigs, OnClientRecieveEnchantingUpgradesConfigs); - AutoSorterConfigurationRPC = NetworkManager.Instance.AddRPC("AutoSorterConfigurationRPC", + AutoSorterConfigurationRPC = NetworkManager.Instance.AddRPC("AutoSorterConfigurationRPC", OnServerRecieveConfigs, OnClientRecieveAutoSorterConfigs); + ShardStonesRPC = NetworkManager.Instance.AddRPC("epicloot_shardstones_RPC", + OnServerRecieveConfigs, OnClientRecieveShardStonesConfigs); + ShardStoneConversionsRPC = NetworkManager.Instance.AddRPC("epicloot_shardstoneconversions_RPC", + OnServerRecieveConfigs, OnClientRecieveShardStoneConversionsConfigs); } - private void CreateConfigValues(ConfigFile Config) - { - // Item Colors - _magicRarityColor = Config.Bind("Item Colors", "Magic Rarity Color", "Blue", - "The color of Magic rarity items, the lowest magic item tier. " + - "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + - "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); - _magicMaterialIconColor = Config.Bind("Item Colors", "Magic Crafting Material Icon Index", 5, - "Indicates the color of the icon used for magic crafting materials. A number between 0 and 9.\n" + - "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); - _rareRarityColor = Config.Bind("Item Colors", "Rare Rarity Color", "Yellow", - "The color of Rare rarity items, the second magic item tier. " + - "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + - "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); - _rareMaterialIconColor = Config.Bind("Item Colors", "Rare Crafting Material Icon Index", 2, - "Indicates the color of the icon used for rare crafting materials. A number between 0 and 9.\n" + - "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); - _epicRarityColor = Config.Bind("Item Colors", "Epic Rarity Color", "Purple", - "The color of Epic rarity items, the third magic item tier. " + - "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + - "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); - _epicMaterialIconColor = Config.Bind("Item Colors", "Epic Crafting Material Icon Index", 7, - "Indicates the color of the icon used for epic crafting materials. A number between 0 and 9.\n" + - "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); - _legendaryRarityColor = Config.Bind("Item Colors", "Legendary Rarity Color", "Teal", - "The color of Legendary rarity items, the fourth magic item tier. " + - "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + - "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); - _legendaryMaterialIconColor = Config.Bind("Item Colors", "Legendary Crafting Material Icon Index", 4, - "Indicates the color of the icon used for legendary crafting materials. A number between 0 and 9.\n" + - "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); - _mythicRarityColor = Config.Bind("Item Colors", "Mythic Rarity Color", "Orange", - "The color of Mythic rarity items, the highest magic item tier. " + - "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + - "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); - _mythicMaterialIconColor = Config.Bind("Item Colors", "Mythic Crafting Material Icon Index", 1, - "Indicates the color of the icon used for legendary crafting materials. A number between 0 and 9.\n" + - "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); - _setItemColor = Config.Bind("Item Colors", "Set Item Color", "#26ffff", - "The color of set item text and the set item icon. Use a hex color, default is cyan"); - - // Crafting UI - UseScrollingCraftDescription = Config.Bind("Crafting UI", "Use Scrolling Craft Description", true, - "Changes the item description in the crafting panel to scroll instead of scale when it gets too " + - "long for the space."); - ShowEquippedAndHotbarItemsInSacrificeTab = Config.Bind("Crafting UI", - "ShowEquippedAndHotbarItemsInSacrificeTab", false, - "If set to false, hides the items that are equipped or on your hotbar in the Sacrifice items list."); - UIAudioVolumeAdjustment = Config.Bind("Crafting UI", "AudioVolumeAdjustment", 1.0f, - new ConfigDescription("Multiplies the crafting UI sound volume by this percentage [0.0-1.0].\n" + - "1 = full UI sounds\n" + - "0 = no UI sounds", - new AcceptableValueRange(0, 1))); - - // Logging - _loggingEnabled = Config.Bind("Logging", "Logging Enabled", true, "Enable logging"); - _logLevel = Config.Bind("Logging", "Log Level", LogLevel.Error, - "Only log messages of the selected level or higher"); - - // General - UseGeneratedMagicItemNames = Config.Bind("General", "Use Generated Magic Item Names", true, + private static void CreateConfigValues() { + // 1 - General + UseGeneratedMagicItemNames = BindClient(SectionGeneral, "Use Generated Magic Item Names", true, "If true, magic items uses special, randomly generated names based on their rarity, type, and magic effects."); - AutoAddEquipment = BindServerConfig("General", "Auto Add Equipment", true, + KeepInventoryOpenOverItems = BindClient(SectionGeneral, "Keep Inventory Open Over Items", true, + "When true, pressing Use while the cursor is over an inventory item never closes the " + + "inventory, so a shard slot press that misses its target does nothing instead of " + + "shutting everything. Use over an empty slot still closes the inventory as normal."); + AutoAddEquipment = BindServer(SectionGeneral, "Auto Add Equipment", true, "Automatically adds equipment types that can be enchanted to possible drops and gates them" + "behind their respective bosses. Disabling this also disables automatic removal of items not found."); - AutoRemoveEquipmentNotFound = BindServerConfig("General", "Auto Remove Equipment Not Found", true, + AutoRemoveEquipmentNotFound = BindServer(SectionGeneral, "Auto Remove Equipment Not Found", true, "Automatically removes equipment types that is not found when loading the game."); - OnlyAddEquipmentWithRecipes = BindServerConfig("General", "Only Add Equipment With Recipes", true, + OnlyAddEquipmentWithRecipes = BindServer(SectionGeneral, "Only Add Equipment With Recipes", true, "Equipment must be able to be created by a recipe in order to automatically get selected. " + "If this is disabled enemy weapons can be added to drops, they are not always valid."); - AutoAddRemoveEquipmentFromVendor = BindServerConfig("General", "Auto Add Remove Equipment From Vendor", true, + AutoAddRemoveEquipmentFromVendor = BindServer(SectionGeneral, "Auto Add Remove Equipment From Vendor", true, "Automatically adds/removes equipment from the vendor when it is added/removed from the game. "); - AutoAddRemoveEquipmentFromLootLists = BindServerConfig("General", "Auto Add Remove Equipment From Loot Lists", true, + AutoAddRemoveEquipmentFromLootLists = BindServer(SectionGeneral, "Auto Add Remove Equipment From Loot Lists", true, "Automatically adds/removes equipment from the tier based loot lists, and validates other loot lists only contain valid items."); - // Balance - BalanceConfigurationType = BindServerConfig("Balance", "Balance Template", "Default", + // 2 - Balance + BalanceConfigurationType = BindServer(SectionBalance, "Balance Template", "Default", "Sets the type of balance configuration to use. " + "When initially set can change the value of other configurations in this file.\n" + "balanced: the recommended balancing, enchantments are powerful but stronger enemies can be a threat.\n" + "minimal: reduced enchantment power to be used with vanilla difficulty options.\n" + "legendary: legacy balancing that can make players godlike.", new AcceptableValueList("balanced", "legendary", "minimal")); - _gatedItemTypeModeConfig = BindServerConfig("Balance", "Item Drop Limits", + _gatedItemTypeModeConfig = BindServer(SectionBalance, "Item Drop Limits", GatedItemTypeMode.BossKillUnlocksCurrentBiomeItems, "Sets how the drop system limits what item types can drop. " + "Unlimited: no limits, exactly what's in the loot table will drop.\n" + @@ -271,126 +318,392 @@ private void CreateConfigValues(ConfigFile Config) "PlayerMustHaveCraftedItem: (local world only) the item can drop if the player has already crafted it " + "or otherwise picked it up. If an item type cannot drop, it will downgrade to an item of the same type and " + "skill that the player has unlocked (i.e. swords will stay swords) according to iteminfo.json."); - BossBountyMode = BindServerConfig("Balance", "Gated Bounty Mode", GatedBountyMode.Unlimited, - "Sets whether available bounties are ungated or gated by boss kills."); - GatedFreebuildMode = Config.Bind("Balance", "Gated Freebuild Mode", GatedPieceTypeMode.BossKillUnlocksCurrentBiomePieces, + GatedFreebuildMode = BindServer(SectionBalance, "Gated Freebuild Mode", GatedPieceTypeMode.BossKillUnlocksCurrentBiomePieces, "Sets whether available pieces for the Freebuild effect are ungated or gated by boss kills."); - _bossTrophyDropMode = BindServerConfig("Balance", "Boss Trophy Drop Mode", BossDropMode.OnePerPlayerNearBoss, + GlobalDropRateModifier = BindServer(SectionBalance, "Global Drop Rate Modifier", 1.0f, + "A global percentage that modifies how likely loot is to drop.\n" + + "1 = Exactly what is in the loot tables will drop.\n" + + "0 = Nothing will drop.\n" + + "2 = The number of items in the drop table are twice as likely to drop " + + "(note, this doesn't double the number of loot dropped, just doubles the relative chance for it to drop).\n" + + "Min = 0, Max = 4", new AcceptableValueRange(minValue: 0, maxValue: 4)); + // The four ratios below are RELATIVE WEIGHTS, not independent chances. Every drop the loot + // tables produce rolls exactly one of these four categories, weighted against each other, so + // they need not sum to 1 -- only their proportions matter. Setting one to 0 removes that + // category. A drop no other category is eligible for (and the case where every weight is 0) + // falls back to a normal item, so loot never silently vanishes. + ItemDropRatio = BindServer(SectionBalance, "Item Drop Ratio", 0.7f, + "Relative weight for a loot drop being a normal (possibly enchanted) item.\n" + + "Weighed against Shard Stone Drop Ratio, Items Unidentified Drop Ratio and Materials " + + "Drop Ratio; the four need not add up to 1, only their proportions matter.\n" + + "0 = never drop plain items, except where no other category is possible for that drop.\n" + + "Min = 0, Max = 1", new AcceptableValueRange(minValue: 0, maxValue: 1)); + ShardStoneDropRatio = BindServer(SectionBalance, "Shard Stone Drop Ratio", 0.2f, + "Relative weight for a loot drop being a shard stone, chosen from the shard set assigned " + + "to the biome at the drop point (the ShardStone_{Biome} item sets in loottables.json).\n" + + "Weighed against Item Drop Ratio, Items Unidentified Drop Ratio and Materials Drop Ratio.\n" + + "0 = no shard stones drop from normal loot. Elite creature and boss shard drops come " + + "from the loot tables directly and are not affected by this setting.\n" + + "Min = 0, Max = 1", new AcceptableValueRange(minValue: 0, maxValue: 1)); + ItemsUnidentifiedDropRatio = BindServer(SectionBalance, "Items Unidentified Drop Ratio", 0.1f, + "Relative weight for a loot drop being an unidentified item.\n" + + "Weighed against Item Drop Ratio, Shard Stone Drop Ratio and Materials Drop Ratio.\n" + + "Only equippable loot can become unidentified, so drops of materials and other " + + "non-equipment ignore this weight.\n" + + "0 = no unidentified items drop.\n" + + "Min = 0, Max = 1", new AcceptableValueRange(minValue: 0, maxValue: 1)); + MaterialsDropRatio = BindServer(SectionBalance, "Materials Drop Ratio", 0.1f, + "Relative weight for a loot drop being magic crafting materials instead of the item " + + "itself, as though that item had been sacrificed.\n" + + "Weighed against Item Drop Ratio, Shard Stone Drop Ratio and Items Unidentified Drop Ratio.\n" + + "0 = no materials drop.\n" + + "Min = 0, Max = 1", new AcceptableValueRange(minValue: 0, maxValue: 1)); + SetItemDropChance = BindServer(SectionBalance, "Set Item Drop Chance", 0.15f, + "The percent chance that a legendary or mythic special item will be dropped, enchanted, " + + "or identified as a set item from the legendaries configuration file.\n" + + "Min = 0, Max = 1", + new AcceptableValueRange(minValue: 0, maxValue: 1)); + TransferMagicItemToCrafts = BindServer(SectionBalance, "Transfer Enchants to Crafted Items", true, + "When enchanted items are used as ingredients in recipes, transfer every enchantment from the " + + "consumed items that is valid on the newly crafted item, along with the highest socket count. " + + "Default: True."); + DeferChestLootRoll = BindServer(SectionBalance, "Defer Chest Loot Roll", true, + "When true, a loot chest's EpicLoot contents are rolled the first time a player reads them " + + "(hovers it, opens it, quick-loots it, or breaks it) rather than when the chest spawns.\n" + + "This matters because a dungeon's interior is created as soon as a player enters the zone " + + "above it, so with this off a crypt passed by early in a playthrough keeps low-tier fallback " + + "loot forever. Deferring means the roll sees the boss keys and Item Drop Limits in effect " + + "when the chest is actually reached.\n" + + "Only applies to chests generated from now on; chests that already rolled keep their contents."); + _bossTrophyDropMode = BindServer(SectionBalance, "Boss Trophy Drop Mode", BossDropMode.OnePerPlayerNearBoss, "Sets bosses to drop a number of trophies equal to the number of players. " + "Optionally set it to only include players within a certain distance, " + "use 'Boss Trophy Drop Player Range' to set the range."); - _bossTrophyDropPlayerRange = BindServerConfig("Balance", "Boss Trophy Drop Player Range", 100.0f, + _bossTrophyDropPlayerRange = BindServer(SectionBalance, "Boss Trophy Drop Player Range", 100.0f, "Sets the range that bosses check when dropping multiple trophies using the OnePerPlayerNearBoss drop mode."); - _bossCryptKeyDropMode = BindServerConfig("Balance", "Crypt Key Drop Mode", BossDropMode.OnePerPlayerNearBoss, + _bossCryptKeyDropMode = BindServer(SectionBalance, "Crypt Key Drop Mode", BossDropMode.OnePerPlayerNearBoss, "Sets bosses to drop a number of crypt keys equal to the number of players. " + "Optionally set it to only include players within a certain distance, " + "use 'Crypt Key Drop Player Range' to set the range."); - _bossCryptKeyDropPlayerRange = BindServerConfig("Balance", "Crypt Key Drop Player Range", 100.0f, + _bossCryptKeyDropPlayerRange = BindServer(SectionBalance, "Crypt Key Drop Player Range", 100.0f, "Sets the range that bosses check when dropping multiple crypt keys using the OnePerPlayerNearBoss drop mode."); - _bossWishboneDropMode = BindServerConfig("Balance", "Wishbone Drop Mode", BossDropMode.OnePerPlayerNearBoss, + _bossWishboneDropMode = BindServer(SectionBalance, "Wishbone Drop Mode", BossDropMode.OnePerPlayerNearBoss, "Sets bosses to drop a number of wishbones equal to the number of players. " + "Optionally set it to only include players within a certain distance, " + "use 'Crypt Key Drop Player Range' to set the range."); - _bossWishboneDropPlayerRange = BindServerConfig("Balance", "Wishbone Drop Player Range", 100.0f, + _bossWishboneDropPlayerRange = BindServer(SectionBalance, "Wishbone Drop Player Range", 100.0f, "Sets the range that bosses check when dropping multiple wishbones using the OnePerPlayerNearBoss drop mode."); - - _adventureModeEnabled = BindServerConfig("Balance", "Adventure Mode Enabled", true, + // 3 - Sockets + AllowDuplicateSocketedEffects = BindServer(SectionSockets, "Allow Duplicate Socketed Effects", false, + "When false, an effect that is already socketed on an item cannot be socketed again."); + AllowShardstoneDuplicateItemEffect = BindServer(SectionSockets, "Allow Shardstone On Matching Item Effect", false, + "When true, a shardstone may be socketed even when the item already has that same effect from being enchanted/rolled."); + AllowRunestoneDuplicateItemEffect = BindServer(SectionSockets, "Allow Runestone On Matching Item Effect", false, + "When true, a runestone may be socketed even when the item already has that same effect from being enchanted/rolled."); + ShardSocketRemovalMode = BindServer(SectionSockets, "Shard Removal Mode", ShardSocketMode.BreakValueless, + "Controls whether a shardstone can be taken back out of a socket once it has been placed. " + + "Shards can always be inserted; this only affects removal.\n" + + "Free = shards can be freely removed and moved to another item.\n" + + "BreakValueless = a shard granting an effect that has no rarity-scaled value (Warmth, for " + + "example) must be broken to be removed, destroying it. A shard granting a rarity-scaled " + + "value, or granting nothing at all on that item, can still be freely removed.\n" + + "BreakAll = every shard must be broken to be removed, destroying it.\n" + + "Permanent = every shard is permanent; it can be neither removed nor broken.\n" + + "Default: BreakValueless."); + RuneSocketRemovalMode = BindServer(SectionSockets, "Rune Removal Mode", RuneSocketMode.Free, + "Controls whether a runestone can be taken back out of a socket once it has been placed. " + + "Runes can always be inserted; this only affects removal.\n" + + "Free = runes can be freely removed and moved to another item.\n" + + "Break = a socketed rune must be broken to be removed, destroying it.\n" + + "Permanent = a socketed rune is permanent; it can be neither removed nor broken, and no " + + "other rune can be swapped into its socket.\n" + + "Default: Free."); + ShardStackingMode = BindServer(SectionSockets, "Shard Stack Mode", ShardStackMode.Diminishing, + "Controls whether more than one shardstone of the same color may sit on a single item. " + + "A shard's effect comes from its color and the item's type, so two shards of one color " + + "always grant the same effect.\n" + + "Blocked = a color already socketed on an item cannot be socketed into it again.\n" + + "Diminishing = allowed, with each further shard of that color contributing a decayed " + + "fraction of its value (see Shard Stack Decay Factor).\n" + + "Full = allowed at full value; every shard of the color contributes its whole effect.\n" + + "Shards whose effect has no rarity-scaled value (Warmth, for example) can never be " + + "stacked -- halving a yes/no effect would leave a dead socket. Boss and other exclusive " + + "shards keep their own one-per-item / one-per-worn-set rule regardless of this setting.\n" + + "Default: Diminishing."); + ShardStackDecayFactor = BindServer(SectionSockets, "Shard Stack Decay Factor", 0.5f, + "Under Shard Stack Mode = Diminishing, the share of its value each further shard of the " + + "same color contributes. The shards of a color are ranked strongest first, and the " + + "shard at rank R is worth (factor ^ R) of its normal value -- so 0.5 gives full, half, " + + "a quarter, an eighth, and so on down the stack.\n" + + "0 = additional shards of a color contribute nothing.\n" + + "1 = no decay (equivalent to Shard Stack Mode = Full).\n" + + "Min = 0, Max = 1", new AcceptableValueRange(minValue: 0, maxValue: 1)); + RuneExtractItemMode = BindServer(SectionSockets, "Rune Extract Mode", RuneExtractMode.ReduceEnchants, + "Controls what happens to the source item when a rune is extracted from it.\n" + + "KeepItem = the item is returned untouched.\n" + + "ReduceEnchants = the extracted enchantment is removed from the item (item kept).\n" + + "ReduceEnchantsAndRarity = the extracted enchantment is removed, the item's rarity is reduced " + + "one tier, and remaining effect values are clamped to the new rarity's max.\n" + + "DestroyItem = the item is consumed.\n" + + "If the extracted enchantment is the item's only enchantment, the item reverts to a normal item.\n" + + "Default: ReduceEnchants."); + + // 4 - Enchanting Table + EnchantingTableUpgradesActive = BindServer(SectionEnchanting, "Upgrades Active", true, + "Toggles Enchanting Table Upgrade Capabilities. If false, enchanting table features will be unlocked set to Level 1"); + EnchantingTableActivatedTabs = BindServer(SectionEnchanting, "Table Features Active", + EnchantingTabs.Sacrifice | EnchantingTabs.Augment | EnchantingTabs.Enchant | EnchantingTabs.Disenchant | + EnchantingTabs.Upgrade | EnchantingTabs.ConvertMaterials | EnchantingTabs.Rune, + "Toggles Enchanting Table Feature on and off completely."); + TemperBaseChance = BindServer(SectionEnchanting, "Temper Base Chance", 0.5f, + "Base chance to temper item when below max value. When effect value is at max, the chance is at the base. If value is above max value, the chance is reduces by the decrement chance. Default value: 0.5"); + TemperDecrement = BindServer(SectionEnchanting, "Temper Decrement Amount", 0.15f, + "Decrement amount when effect value is above max value. Does not apply if value is below max value. Decrement amount is multiplied by the increment amount the value is above max value. Default value: 0.15"); + TemperDestroysItem = BindServer(SectionEnchanting, "Temper Fail Destroys Item", false, + "When tempering fails, the item will be destroyed. If False, the item will be returned intact. Default value: False"); + TemperChanceToDestroy = BindServer(SectionEnchanting, "Temper Destroy Chance", 0.5f, + "If Fail Destroys Item is enabled, Destroy Chance rolls if item should be destroyed. Default value: 0.5"); + + // 5 - Adventure + _adventureModeEnabled = BindServer(SectionAdventure, "Adventure Mode Enabled", true, "Set to true to enable all the adventure mode features: secret stash, gambling, treasure maps, and bounties. " + "Set to false to disable. This will not actually remove active treasure maps or bounties from your save."); - _adventureModeEnabled.SettingChanged += (_, _) => MinimapController.RefreshAdventureToggleContainer(); - - _andvaranautRange = BindServerConfig("Balance", "Andvaranaut Range", 20, + _andvaranautRange = BindServer(SectionAdventure, "Andvaranaut Range", 20, "Sets the range that Andvaranaut will activate to locate a treasure chest."); - SetItemDropChance = BindServerConfig("Balance", "Set Item Drop Chance", 0.15f, - "The percent chance that a legendary or mythic special item will be dropped, enchanted, " + - "or identified as a set item from the legendaries configuration file.\n" + - "Min = 0, Max = 1", - new AcceptableValueRange(minValue: 0, maxValue: 1)); - GlobalDropRateModifier = BindServerConfig("Balance", "Global Drop Rate Modifier", 1.0f, - "A global percentage that modifies how likely loot is to drop.\n" + - "1 = Exactly what is in the loot tables will drop.\n" + - "0 = Nothing will drop.\n" + - "2 = The number of items in the drop table are twice as likely to drop " + - "(note, this doesn't double the number of loot dropped, just doubles the relative chance for it to drop).\n" + - "Min = 0, Max = 4", new AcceptableValueRange(minValue: 0, maxValue: 4)); - ItemsUnidentifiedDropRatio = BindServerConfig("Balance", "Items Unidentified Drop Ratio", 0.0f, - "Sets the chance that loot is dropped as unidentified items. " + - "This value is set first, " + - "Items To Materials Drop Ratio uses the remaining value from this configuration for ratio calculation.\n" + - "0 = no unidentified items drop, uses only the Items To Materials Drop Ratio.\n" + - "1 = only unidentified items drop.", - new AcceptableValueRange(minValue: 0, maxValue: 1)); - ItemsToMaterialsDropRatio = BindServerConfig("Balance", "Items To Materials Drop Ratio", 0.0f, - "Sets the chance, using the remaining value from Items Unidentified Drop Ratio, " + - "that loot drops are instead dropped as magic crafting materials.\n" + - "0 = all items, no materials.\n" + - "1 = all materials, no items. Values between 0 and 1 change the ratio of items to materials that drop.\n" + - "At 0.5, half of everything that drops would be items and the other half would be materials.\n" + - "Min = 0, Max = 1", new AcceptableValueRange(minValue: 0, maxValue: 1)); - TransferMagicItemToCrafts = BindServerConfig("Balance", "Transfer Enchants to Crafted Items", false, - "When enchanted items are used as ingredients in recipes, transfer the highest enchant to the " + - "newly crafted item. Default: False."); - RuneExtractDestroysItem = BindServerConfig("Balance", "Rune Extract Destroys Item", true, - "When extracting a rune from an item, the item will be destroyed. If false, the item will be returned intact. " + - "Default: True."); + BossBountyMode = BindServer(SectionAdventure, "Gated Bounty Mode", GatedBountyMode.Unlimited, + "Sets whether available bounties are ungated or gated by boss kills."); + EnableLimitedBountiesInProgress = BindServer(SectionAdventure, "Enable Bounty Limit", false, + "Toggles limiting bounties. Players unable to purchase if enabled and maximum bounty in-progress count is met"); + MaxInProgressBounties = BindServer(SectionAdventure, "Max Bounties Per Player", 5, + "Max amount of in-progress bounties allowed per player."); - // Debug - AlwaysShowWelcomeMessage = Config.Bind("Debug", "Show Welcome Message, automatically set to false once config is viewed.", true, - "Sets whether or not the welcome message is displayed on startup, this is automatically set to false once the player has viewed the message."); - OutputPatchedConfigFiles = Config.Bind("Debug", "OutputPatchedConfigFiles", false, - "Just a debug flag for testing the patching system, do not use."); - EnableHotReloadPatches = BindServerConfig("Debug", "Enable Hot Reloading Patches", true, - "Controls whether or not patch edits can be live-reloaded. Can cause lag when recompiling patches."); - AlwaysRefreshCoreConfigs = BindServerConfig("Debug", "Always Refresh Core Configs", false, - "Overwrites your core configuration with the mod default values on startup. THIS WILL DELETE ANY MODIFICATIONS TO THE CORE CONFIG."); + // 6 - Interface + UseScrollingCraftDescription = BindClient(SectionInterface, "Use Scrolling Craft Description", true, + "Changes the item description in the crafting panel to scroll instead of scale when it gets too " + + "long for the space."); + ShowRarityInRecipeList = BindClient(SectionInterface, "Show Rarity In Recipe List", true, + "Shows the magic item rarity background behind item icons in the crafting panel's recipe " + + "list and detail panel, the same way it is shown in your inventory."); + ShowEnchantSelectionChance = BindServer(SectionInterface, "Show Enchant Selection Chance", false, + "When true, the Enchant and Augment panels show the weighted chance that each available effect " + + "is selected on a single roll, displayed right after the bullet for each effect."); + ShowEquippedAndHotbarItemsInSacrificeTab = BindClient(SectionInterface, + "ShowEquippedAndHotbarItemsInSacrificeTab", false, + "If set to false, hides the items that are equipped or on your hotbar in the Sacrifice items list."); + UIAudioVolumeAdjustment = BindClient(SectionInterface, "AudioVolumeAdjustment", 1.0f, + "Multiplies the crafting UI sound volume by this percentage [0.0-1.0].\n" + + "1 = full UI sounds\n" + + "0 = no UI sounds", + new AcceptableValueRange(0, 1)); + TooltipMaxWidth = BindClient(SectionInterface, "Tooltip Max Width", 350, + "Maximum width of the item tooltip box, in pixels.", new AcceptableValueRange(150, 1200)); + TooltipMaxHeight = BindClient(SectionInterface, "Tooltip Max Height", 650, + "Maximum height of the item tooltip box, in pixels. Content taller than this scrolls.", + new AcceptableValueRange(350, 4000)); + TraderPanelPositionX = BindClient(SectionInterface, "Trader Panel X Position", -200f, + "The horizontal on-screen position (RectTransform anchoredPosition X, anchored to the " + + "top-right of the trader window) of the EpicLoot adventure trader panel. Dragging the " + + "panel in-game updates this automatically. Default: -200. More negative moves it left, " + + "toward 0 (and positive) moves it right."); + TraderPanelPositionY = BindClient(SectionInterface, "Trader Panel Y Position", -155f, + "The vertical on-screen position (RectTransform anchoredPosition Y, anchored to the " + + "top-right of the trader window) of the EpicLoot adventure trader panel. Dragging the " + + "panel in-game updates this automatically. Default: -155."); + TemperPanelPositionX = BindClient(SectionInterface, "Temper Panel X Position", -200f, + "The horizontal on-screen position (RectTransform anchoredPosition X, anchored to the " + + "top-right of the trader window) of the EpicLoot tempering panel. Dragging the panel " + + "in-game updates this automatically. Default: -200."); + TemperPanelPositionY = BindClient(SectionInterface, "Temper Panel Y Position", -155f, + "The vertical on-screen position (RectTransform anchoredPosition Y, anchored to the " + + "top-right of the trader window) of the EpicLoot tempering panel. Dragging the panel " + + "in-game updates this automatically. Default: -155."); + + // 7 - Item Colors + _magicRarityColor = BindClient(SectionItemColors, "Magic Rarity Color", "Blue", + "The color of Magic rarity items, the lowest magic item tier. " + + "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + + "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); + _magicMaterialIconColor = BindClient(SectionItemColors, "Magic Crafting Material Icon Index", 5, + "Indicates the color of the icon used for magic crafting materials. A number between 0 and 9.\n" + + "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); + _rareRarityColor = BindClient(SectionItemColors, "Rare Rarity Color", "Yellow", + "The color of Rare rarity items, the second magic item tier. " + + "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + + "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); + _rareMaterialIconColor = BindClient(SectionItemColors, "Rare Crafting Material Icon Index", 2, + "Indicates the color of the icon used for rare crafting materials. A number between 0 and 9.\n" + + "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); + _epicRarityColor = BindClient(SectionItemColors, "Epic Rarity Color", "Purple", + "The color of Epic rarity items, the third magic item tier. " + + "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + + "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); + _epicMaterialIconColor = BindClient(SectionItemColors, "Epic Crafting Material Icon Index", 7, + "Indicates the color of the icon used for epic crafting materials. A number between 0 and 9.\n" + + "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); + _legendaryRarityColor = BindClient(SectionItemColors, "Legendary Rarity Color", "Teal", + "The color of Legendary rarity items, the fourth magic item tier. " + + "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + + "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); + _legendaryMaterialIconColor = BindClient(SectionItemColors, "Legendary Crafting Material Icon Index", 4, + "Indicates the color of the icon used for legendary crafting materials. A number between 0 and 9.\n" + + "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); + _mythicRarityColor = BindClient(SectionItemColors, "Mythic Rarity Color", "Orange", + "The color of Mythic rarity items, the highest magic item tier. " + + "(Optional, use an HTML hex color starting with # to have a custom color.)\n" + + "Available options: Red, Orange, Yellow, Green, Teal, Blue, Indigo, Purple, Pink, Gray"); + _mythicMaterialIconColor = BindClient(SectionItemColors, "Mythic Crafting Material Icon Index", 1, + "Indicates the color of the icon used for legendary crafting materials. A number between 0 and 9.\n" + + "Available options: 0=Red, 1=Orange, 2=Yellow, 3=Green, 4=Teal, 5=Blue, 6=Indigo, 7=Purple, 8=Pink, 9=Gray"); + _setItemColor = BindClient(SectionItemColors, "Set Item Color", "#26ffff", + "The color of set item text and the set item icon. Use a hex color, default is cyan"); - // Abilities - AbilityKeyCodes[0] = Config.Bind("Abilities", "Ability Hotkey 1", "g", "Hotkey for Ability Slot 1."); - AbilityKeyCodes[1] = Config.Bind("Abilities", "Ability Hotkey 2", "h", "Hotkey for Ability Slot 2."); - AbilityKeyCodes[2] = Config.Bind("Abilities", "Ability Hotkey 3", "j", "Hotkey for Ability Slot 3."); - AbilityBarAnchor = Config.Bind("Abilities", "Ability Bar Anchor", TextAnchor.LowerLeft, + // 8 - Abilities + AbilityKeyCodes[0] = BindClient(SectionAbilities, "Ability Hotkey 1", "g", "Hotkey for Ability Slot 1."); + AbilityKeyCodes[1] = BindClient(SectionAbilities, "Ability Hotkey 2", "h", "Hotkey for Ability Slot 2."); + AbilityKeyCodes[2] = BindClient(SectionAbilities, "Ability Hotkey 3", "j", "Hotkey for Ability Slot 3."); + AbilityBarAnchor = BindClient(SectionAbilities, "Ability Bar Anchor", TextAnchor.LowerLeft, "The point on the HUD to anchor the ability bar. Changing this also changes the pivot of the ability bar to that corner. " + "For reference: the ability bar size is 208 by 64."); - AbilityBarPosition = Config.Bind("Abilities", "Ability Bar Position", new Vector2(150, 170), + AbilityBarPosition = BindClient(SectionAbilities, "Ability Bar Position", new Vector2(150, 170), "The position offset from the Ability Bar Anchor at which to place the ability bar."); - AbilityBarLayoutAlignment = Config.Bind("Abilities", "Ability Bar Layout Alignment", TextAnchor.LowerLeft, + AbilityBarLayoutAlignment = BindClient(SectionAbilities, "Ability Bar Layout Alignment", TextAnchor.LowerLeft, "The Ability Bar is a Horizontal Layout Group. This value indicates how the elements inside are aligned. " + "Choices with 'Center' in them will keep the items centered on the bar, even if there are fewer than the maximum allowed. " + "'Left' will be left aligned, and similar for 'Right'."); - AbilityBarIconSpacing = Config.Bind("Abilities", "Ability Bar Icon Spacing", 8.0f, + AbilityBarIconSpacing = BindClient(SectionAbilities, "Ability Bar Icon Spacing", 8.0f, "The number of units between the icons on the ability bar."); - // Enchanting Table - EnchantingTableUpgradesActive = BindServerConfig("Enchanting Table", "Upgrades Active", true, - "Toggles Enchanting Table Upgrade Capabilities. If false, enchanting table features will be unlocked set to Level 1"); - EnchantingTableActivatedTabs = BindServerConfig("Enchanting Table", $"Table Features Active", - EnchantingTabs.Sacrifice | EnchantingTabs.Augment | EnchantingTabs.Enchant | EnchantingTabs.Disenchant | - EnchantingTabs.Upgrade | EnchantingTabs.ConvertMaterials | EnchantingTabs.Rune, - $"Toggles Enchanting Table Feature on and off completely."); + // 9 - Debug + _loggingEnabled = BindClient(SectionDebug, "Logging Enabled", true, "Enable logging"); + _logLevel = BindClient(SectionDebug, "Log Level", LogLevel.Error, + "Only log messages of the selected level or higher"); + AlwaysShowWelcomeMessage = BindClient(SectionDebug, "Show Welcome Message, automatically set to false once config is viewed.", true, + "Sets whether or not the welcome message is displayed on startup, this is automatically set to false once the player has viewed the message."); + OutputPatchedConfigFiles = BindClient(SectionDebug, "OutputPatchedConfigFiles", false, + "Just a debug flag for testing the patching system, do not use."); + EnableHotReloadPatches = BindServer(SectionDebug, "Enable Hot Reloading Patches", true, + "Controls whether or not patch edits can be live-reloaded. Can cause lag when recompiling patches."); + AlwaysRefreshCoreConfigs = BindServer(SectionDebug, "Always Refresh Core Configs", false, + "Overwrites your core configuration with the mod default values on startup. THIS WILL DELETE ANY MODIFICATIONS TO THE CORE CONFIG."); + } + + /// + /// Subscribed after so restoring a migrated value cannot push + /// a UI refresh through before Awake has built the UI. + /// + private static void RegisterSettingChangedHandlers() { + TraderPanelPositionX.SettingChanged += (_, _) => + MerchantPanel.Instance?.ApplyConfiguredPosition(); + TraderPanelPositionY.SettingChanged += (_, _) => + MerchantPanel.Instance?.ApplyConfiguredPosition(); + TemperPanelPositionX.SettingChanged += (_, _) => + global::EpicLoot.TemperPanel.Instance?.ApplyConfiguredPosition(); + TemperPanelPositionY.SettingChanged += (_, _) => + global::EpicLoot.TemperPanel.Instance?.ApplyConfiguredPosition(); + _adventureModeEnabled.SettingChanged += (_, _) => MinimapController.RefreshAdventureToggleContainer(); EnchantingTableUpgradesActive.SettingChanged += (_, _) => EnchantingTableUI.UpdateUpgradeActivation(); EnchantingTableActivatedTabs.SettingChanged += (_, _) => EnchantingTableUI.UpdateTabActivation(); + } - // Bounty Management - EnableLimitedBountiesInProgress = BindServerConfig("Bounty Management", "Enable Bounty Limit", false, - "Toggles limiting bounties. Players unable to purchase if enabled and maximum bounty in-progress count is met"); - MaxInProgressBounties = BindServerConfig("Bounty Management", "Max Bounties Per Player", 5, - "Max amount of in-progress bounties allowed per player."); + /// Binds a client-local entry in declaration order, recording where it landed for the migration. + private static ConfigEntry BindClient(string section, string key, T value, string description, + AcceptableValueBase acceptableValues = null) { + return Track(ConfigBinder.BindClientConfigInOrder(section, key, value, description, acceptableValues), section, key); + } + + /// Binds a server-authoritative (admin only) entry in declaration order. + private static ConfigEntry BindServer(string section, string key, T value, string description, + AcceptableValueBase acceptableValues = null) { + return Track(ConfigBinder.BindServerConfigInOrder(section, key, value, description, acceptableValues), section, key); + } + + private static ConfigEntry Track(ConfigEntry entry, string section, string key) { + BoundEntries.Add((entry, $"{section}::{key}")); + return entry; + } + + /// + /// Snapshots the config file as it stands on disk, keyed by section-without-its-order-prefix. Reading + /// the file rather than the bound entries is what lets a setting be found under a name BepInEx no + /// longer binds; unbound entries survive in the file (BepInEx writes its orphans back out) but are + /// not reachable through the ConfigFile API. + /// + private static void ReadPreviousConfigValues() { + PreviousConfigValues.Clear(); + if (!File.Exists(cfg.ConfigFilePath)) { + return; + } + + string section = string.Empty; + bool sectionIsOrdered = false; + foreach (string rawLine in File.ReadAllLines(cfg.ConfigFilePath)) { + string line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') { + continue; + } + + if (line[0] == '[' && line[line.Length - 1] == ']') { + section = line.Substring(1, line.Length - 2).Trim(); + Match orderPrefix = Regex.Match(section, @"^\d+\s*-\s*"); + sectionIsOrdered = orderPrefix.Success; + section = sectionIsOrdered ? section.Substring(orderPrefix.Length) : section; + continue; + } + + int separator = line.IndexOf('='); + if (separator <= 0) { + continue; + } + + // Other binders write sections this file does not own - PieceLoader names one after each + // piece, so "[Enchanting Table]" holds the table's build cost while "[4 - Enchanting Table]" + // holds its features. Stripping the prefix makes those two collide, so an ordered section + // always wins; anything else only fills a gap. + string location = $"{section}::{line.Substring(0, separator).Trim()}"; + if (sectionIsOrdered || !PreviousConfigValues.ContainsKey(location)) { + PreviousConfigValues[location] = line.Substring(separator + 1).Trim(); + } + } + } + + /// + /// Restores values the player had set before this run, for entries BepInEx could not match itself. + /// That covers two cases: a section whose order prefix changed (handled by the stripped keys), and a + /// setting that moved or was renamed (handled by ). + /// + private static void ApplyPreviousConfigValues() { + foreach ((ConfigEntryBase entry, string location) in BoundEntries) { + if (!PreviousConfigValues.TryGetValue(location, out string previousValue) + && (!RelocatedSettings.TryGetValue(location, out string previousLocation) + || !PreviousConfigValues.TryGetValue(previousLocation, out previousValue))) { + continue; + } + + // Setting the value it already holds is a no-op, so this only bites when something moved. + entry.SetSerializedValue(previousValue); + } + + BoundEntries.Clear(); + PreviousConfigValues.Clear(); } - public static void InitializeConfig() - { + public static void InitializeConfig() { SychronizeConfig("loottables.json", LootRoller.Initialize, LootTablesRPC, LootRoller.GetCFG); SychronizeConfig("magiceffects.json", MagicItemEffectDefinitions.Initialize, MagicEffectsRPC, MagicItemEffectDefinitions.GetMagicItemEffectDefinitions); + SychronizeConfig("shardstones.json", Shards.InitializeShardDefinitions, + ShardStonesRPC, Shards.GetCFG); + // Must precede materialconversions, whose Initialize fires the event that merges these in. + SychronizeConfig("shardstoneconversions.json", ShardStoneConversions.Initialize, + ShardStoneConversionsRPC, ShardStoneConversions.GetCFG); // Adventure data has to be loaded before iteminfo, as iteminfo uses the adventure data to determine what items can drop SychronizeConfig("adventuredata.json", AdventureDataManager.Initialize, AdventureDataRPC, AdventureDataManager.GetCFG); SychronizeConfig("iteminfo.json", GatedItemTypeHelper.Initialize, ItemConfigRPC, GatedItemTypeHelper.GetCFG); - SychronizeConfig("recipes.json", RecipesHelper.Initialize, RecipesRPC, RecipesHelper.GetCFG); SychronizeConfig("enchantcosts.json", EnchantCostsHelper.Initialize, EnchantingCostsRPC, EnchantCostsHelper.GetCFG); SychronizeConfig("itemnames.json", MagicItemNames.Initialize, ItemNamesRPC, MagicItemNames.GetCFG); @@ -409,77 +722,61 @@ public static void InitializeConfig() } /// - /// Recipes cannot be created until the game is launched. + /// Recipes cannot be created until the game is launched. Epic Loot no longer ships any recipes of its + /// own, so this only applies recipes other mods registered through . /// Watch for issues, this can potentially trigger after client config synchronization and break. /// - private static void InitializeRecipeOnReady() - { - string jsonFile = EpicLoot.ReadEmbeddedResourceFile("EpicLoot.config.recipes.json"); - RecipesConfig result = JsonConvert.DeserializeObject(jsonFile); - - if (RecipesHelper.Config == null) - { - RecipesHelper.Initialize(result); - } - else - { - RecipesHelper.Initialize(RecipesHelper.Config); - } + private static void InitializeRecipeOnReady() { + RecipesHelper.Initialize(RecipesHelper.Config); ItemManager.OnItemsRegistered -= InitializeRecipeOnReady; } - public static string GetLocalizationDirectoryPath() - { + public static string GetLocalizationDirectoryPath() { string localizationFolder = Path.Combine(Paths.ConfigPath, "EpicLoot", "localizations"); DirectoryInfo dirInfo = Directory.CreateDirectory(localizationFolder); return dirInfo.FullName; } - public static string GetOverhaulDirectoryPath() - { + public static string GetOverhaulDirectoryPath() { string overhaulfolder = Path.Combine(Paths.ConfigPath, "EpicLoot", "baseconfig"); DirectoryInfo dirInfo = Directory.CreateDirectory(overhaulfolder); return dirInfo.FullName; } - public static string GetDefaultEmbeddedFileLocation(string configName) - { + public static string GetDefaultEmbeddedFileLocation(string configName) { + // Callers may pass the config name with or without extension; the embedded resource names + // (and the magiceffects overhaul check below) require the ".json" suffix. + if (!configName.EndsWith(".json")) { + configName += ".json"; + } + string embeddedcfgpath = "EpicLoot.config." + configName; - if (configName == "magiceffects.json") - { + if (configName == "magiceffects.json") { embeddedcfgpath = "EpicLoot.config.overhauls." + BalanceConfigurationType.Value + "." + configName; } return embeddedcfgpath; } - public static void CreateBaseConfigurations(string baseCfgLocation, string filename) - { + public static void CreateBaseConfigurations(string baseCfgLocation, string filename) { EpicLoot.Log($"Base config file {baseCfgLocation} being created from embedded default config."); string overhaulFileData = EpicLoot.ReadEmbeddedResourceFile(GetDefaultEmbeddedFileLocation(filename)); File.WriteAllText(baseCfgLocation, overhaulFileData); } - public static void SychronizeConfig(string filename, Action setupMethod, CustomRPC targetRPC, Func getConfig) where T : class - { + public static void SychronizeConfig(string filename, Action setupMethod, CustomRPC targetRPC, Func getConfig) where T : class { string baseCfgLocation = Path.Combine(ELConfig.GetOverhaulDirectoryPath(), filename); - // Ensure that the core config file exists - if (File.Exists(baseCfgLocation) == false || AlwaysRefreshCoreConfigs.Value) - { - CreateBaseConfigurations(baseCfgLocation, filename); - FilePatching.LoadPatchedJSON(filename.Split('.')[0], true); - } + // Ensure the base config file exists and reflects the current patches before we read it. + // LoadPatchedJSON handles the missing-file / AlwaysRefreshCoreConfigs / has-patches cases internally. + FilePatching.LoadPatchedJSON(filename.Split('.')[0]); // Attempt to parse the core config, if its not valid use the embedded default config - try - { + try { string fileContents = File.ReadAllText(baseCfgLocation); T contents = JsonConvert.DeserializeObject(fileContents); setupMethod(contents); - } - catch (Exception e) - { + } catch (Exception e) { EpicLoot.LogWarningForce($"The existing baseconfig file {filename} is invalid! Defaults will be used." + $"\n{e.Message}"); string defaultConfig = EpicLoot.ReadEmbeddedResourceFile(GetDefaultEmbeddedFileLocation(filename)); @@ -488,8 +785,7 @@ public static void SychronizeConfig(string filename, Action setupMethod, C EpicLoot.Log($"Finished loading and applying patches for baseconfig file {filename}."); - ZPackage SendInitialConfig() - { + ZPackage SendInitialConfig() { string cfgs = JsonConvert.SerializeObject(getConfig()); return SendConfig(cfgs); } @@ -498,41 +794,31 @@ ZPackage SendInitialConfig() SynchronizationManager.Instance.AddInitialSynchronization(targetRPC, SendInitialConfig); // Encapsulated file watcher modification method for the config file - void FileModified(object sender, FileSystemEventArgs e) - { - if (e.FullPath != baseCfgLocation || !File.Exists(baseCfgLocation)) - { + void FileModified(object sender, FileSystemEventArgs e) { + if (e.FullPath != baseCfgLocation || !File.Exists(baseCfgLocation)) { return; } EpicLoot.Log($"Config file {baseCfgLocation} {e.FullPath} has been modified, attempting to update config."); bool validUpdate = false; - try - { + try { T contents = JsonConvert.DeserializeObject(File.ReadAllText(baseCfgLocation)); EpicLoot.Log($"Config file {baseCfgLocation} has been modified, updating config."); setupMethod(contents); validUpdate = true; - } - catch (Exception ex) - { + } catch (Exception ex) { EpicLoot.LogWarningForce($"Config file {baseCfgLocation} is invalid and config will not be updated." + ex); } - if (validUpdate == false) - { + if (validUpdate == false) { return; } - if (GUIManager.IsHeadless()) - { - try - { + if (GUIManager.IsHeadless()) { + try { targetRPC.SendPackage(ZNet.instance.m_peers, SendConfig(JsonConvert.SerializeObject(getConfig()))); - } - catch - { + } catch { // TODO check EpicLoot.LogError($"Error while server syncing {filename} configs"); } @@ -551,22 +837,18 @@ void FileModified(object sender, FileSystemEventArgs e) fsw.Filter = filename; } - public static void StartupProcessModifiedLocalizations() - { + public static void StartupProcessModifiedLocalizations() { string[] files = Directory.GetFiles(LocalizationDir, "*", SearchOption.AllDirectories); EpicLoot.Log($"Processing localization startup file patches: {string.Join(",", files)}"); - foreach (string file in files) - { - if (!file.Contains(".json")) - { + foreach (string file in files) { + if (!file.Contains(".json")) { EpicLoot.Log($"File: {file} is not a supported format, ignoring."); continue; } FileInfo fileInfo = new FileInfo(file); string language = file.Trim().Split(Path.DirectorySeparatorChar).Last().Split('.').First().Trim(); - if (!LocalizationLanguages.Contains(language)) - { + if (!LocalizationLanguages.Contains(language)) { EpicLoot.LogWarning($"{language} is not a supported language [{string.Join(", ", LocalizationLanguages.ToArray())}]"); continue; } @@ -581,44 +863,37 @@ public static void StartupProcessModifiedLocalizations() } - private static void IngestPatchFilesFromDisk(object s, FileSystemEventArgs e) - { - if (EnableHotReloadPatches.Value == false) - { + private static void IngestPatchFilesFromDisk(object s, FileSystemEventArgs e) { + if (EnableHotReloadPatches.Value == false) { return; } - if (SynchronizationManager.Instance.PlayerIsAdmin == false) - { + if (SynchronizationManager.Instance.PlayerIsAdmin == false) { EpicLoot.Log("Player is not an admin, and not allowed to change local configuration. Local config change will not be loaded."); return; } // Do not process directories, setup a new watcher- otherwise they get ingored even with subdirectory watching. - if (File.GetAttributes(e.FullPath).HasFlag(FileAttributes.Directory)) - { + if (File.GetAttributes(e.FullPath).HasFlag(FileAttributes.Directory)) { SetupPatchConfigFileWatch(e.FullPath); EpicLoot.Log($"Adding subdirectory filewatcher: {e.FullPath}"); return; } FileInfo fileInfo = new FileInfo(e.FullPath); - if (!fileInfo.FullName.Contains(".json")) - { + if (!fileInfo.FullName.Contains(".json")) { return; } EpicLoot.Log($"Processing patch file update: {fileInfo}"); FilePatching.ReloadAndApplyAllPatches(); - if (AutoAddEquipment.Value == true || AutoRemoveEquipmentNotFound.Value == true) - { + if (AutoAddEquipment.Value == true || AutoRemoveEquipmentNotFound.Value == true) { AutoAddEnchantableItems.CheckAndAddAllEnchantableItems(false); } } - public static void SetupPatchConfigFileWatch(string path) - { + public static void SetupPatchConfigFileWatch(string path) { FileSystemWatcher newPatchWatcher = new FileSystemWatcher(path); newPatchWatcher.Created += new FileSystemEventHandler(IngestPatchFilesFromDisk); newPatchWatcher.Changed += new FileSystemEventHandler(IngestPatchFilesFromDisk); @@ -632,149 +907,97 @@ public static void SetupPatchConfigFileWatch(string path) } - internal static void CheckAndUpdateLocalization(Dictionary localizationUpdates, string language) - { - foreach (KeyValuePair localization in localizationUpdates) - { + internal static void CheckAndUpdateLocalization(Dictionary localizationUpdates, string language) { + foreach (KeyValuePair localization in localizationUpdates) { EpicLoot.Log($"Updating localization: {localization.Key} - {localization.Value}"); LocalizationManager.Instance.GetLocalization().ClearToken(language, localization.Key); LocalizationManager.Instance.GetLocalization().AddTranslation(language, localization.Key, localization.Value); } } - private static IEnumerator OnClientRecieveLootConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveLootConfigs(long sender, ZPackage package) { LootRoller.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveMagicConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveMagicConfigs(long sender, ZPackage package) { MagicItemEffectDefinitions.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveItemInfoConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveItemInfoConfigs(long sender, ZPackage package) { GatedItemTypeHelper.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveRecipesConfigs(long sender, ZPackage package) - { - RecipesHelper.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); - yield return null; - } - - private static IEnumerator OnClientRecieveEnchantingCostsConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveEnchantingCostsConfigs(long sender, ZPackage package) { EnchantCostsHelper.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveItemNameConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveItemNameConfigs(long sender, ZPackage package) { MagicItemNames.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveAdventureDataConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveAdventureDataConfigs(long sender, ZPackage package) { AdventureDataManager.UpdateAventureData(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveLegendaryItemConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveLegendaryItemConfigs(long sender, ZPackage package) { UniqueLegendaryHelper.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveAbilityConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveAbilityConfigs(long sender, ZPackage package) { AbilityDefinitions.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveMaterialConversionConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveMaterialConversionConfigs(long sender, ZPackage package) { MaterialConversions.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveEnchantingUpgradesConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveEnchantingUpgradesConfigs(long sender, ZPackage package) { EnchantingTableUpgrades.InitializeConfig(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static IEnumerator OnClientRecieveAutoSorterConfigs(long sender, ZPackage package) - { + private static IEnumerator OnClientRecieveAutoSorterConfigs(long sender, ZPackage package) { AutoAddEnchantableItems.InitializeConfig(ClientRecieveParseJsonConfig(package.ReadString())); yield return null; } - private static T ClientRecieveParseJsonConfig(string json) - { - try - { + private static IEnumerator OnClientRecieveShardStonesConfigs(long sender, ZPackage package) { + Shards.InitializeShardDefinitions(ClientRecieveParseJsonConfig(package.ReadString())); + yield return null; + } + + private static IEnumerator OnClientRecieveShardStoneConversionsConfigs(long sender, ZPackage package) { + ShardStoneConversions.Initialize(ClientRecieveParseJsonConfig(package.ReadString())); + yield return null; + } + + private static T ClientRecieveParseJsonConfig(string json) { + try { return JsonConvert.DeserializeObject(json); - } - catch (Exception e) - { + } catch (Exception e) { EpicLoot.LogError($"There was an error syncing client configs: {e}"); } return default; } - public static ZPackage SendConfig(string zpackage_content) - { + public static ZPackage SendConfig(string zpackage_content) { ZPackage package = new ZPackage(); package.Write(zpackage_content); return package; } - public static IEnumerator OnServerRecieveConfigs(long sender, ZPackage package) - { + public static IEnumerator OnServerRecieveConfigs(long sender, ZPackage package) { EpicLoot.Log("Server received config from client, rejecting due to being the server."); yield return null; } - /// - /// Helper to bind configs for - /// - /// IsAdminOnly ensures this is a server authoratative value - /// - public static ConfigEntry BindServerConfig(string category, string key, T value, string description, - AcceptableValueList acceptableValues = null, bool advanced = false) - { - return cfg.Bind(category, key, value, - new ConfigDescription( - description, - acceptableValues, - new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }) - ); - } - - public static ConfigEntry BindServerConfig(string category, string key, T value, string description, - AcceptableValueRange acceptableValues, bool advanced = false) - { - return cfg.Bind(category, key, value, - new ConfigDescription( - description, - acceptableValues, - new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }) - ); - } - - public static ConfigEntry BindServerConfig(string category, string key, T value, string description, - AcceptableValueRange acceptableValues, bool advanced = false) - { - return cfg.Bind(category, key, value, - new ConfigDescription( - description, - acceptableValues, - new ConfigurationManagerAttributes { IsAdminOnly = true, IsAdvanced = advanced }) - ); - } } diff --git a/EpicLoot/src/Crafting/AugmentChoiceDialog.cs b/EpicLoot/src/Crafting/AugmentChoiceDialog.cs index ed33707c8..034d8f8bb 100755 --- a/EpicLoot/src/Crafting/AugmentChoiceDialog.cs +++ b/EpicLoot/src/Crafting/AugmentChoiceDialog.cs @@ -1,6 +1,7 @@ using JetBrains.Annotations; using System; using System.Collections.Generic; +using EpicLoot.CraftingV2; using TMPro; using UnityEngine; using UnityEngine.UI; @@ -15,9 +16,6 @@ public class AugmentChoiceDialog : MonoBehaviour public Image MagicBG; public List