diff --git a/EpicLoot/API/Reload.cs b/EpicLoot/API/Reload.cs index b648bdf3..b03e5906 100644 --- a/EpicLoot/API/Reload.cs +++ b/EpicLoot/API/Reload.cs @@ -57,6 +57,7 @@ private static void ReloadExternalTreasures() AdventureDataManager.Config.TreasureMap.BiomeInfo.Add(treasure); } + AdventureDataManager.Config.TreasureMap.UpdateBiomeList(); OnReload?.Invoke("Reloaded external treasures"); } diff --git a/EpicLoot/API/Treasure.cs b/EpicLoot/API/Treasure.cs index 130a5694..066741ea 100644 --- a/EpicLoot/API/Treasure.cs +++ b/EpicLoot/API/Treasure.cs @@ -21,6 +21,9 @@ public static string AddTreasureMap(string json) ExternalTreasureMaps.Add(map); AdventureDataManager.Config.TreasureMap.BiomeInfo.Add(map); + // The spawn-point cache only searches biomes in this list; without the refresh an + // API-added biome never got a radius range or a cached point. + AdventureDataManager.Config.TreasureMap.UpdateBiomeList(); return RuntimeRegistry.Register(map); } catch diff --git a/EpicLoot/EpicLoot.cs b/EpicLoot/EpicLoot.cs index bd74cac8..ef898947 100644 --- a/EpicLoot/EpicLoot.cs +++ b/EpicLoot/EpicLoot.cs @@ -1,6 +1,7 @@ using BepInEx; using Common; using EpicLoot.Adventure; +using EpicLoot.Biomes; using EpicLoot.Config; using EpicLoot.Crafting; using EpicLoot.CraftingV2; @@ -37,7 +38,7 @@ namespace EpicLoot; public sealed class EpicLoot : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.epicloot"; public const string DisplayName = "Epic Loot"; - public const string Version = "0.13.4"; + public const string Version = "0.13.5"; private static string ConfigFileName = PluginId + ".cfg"; private static string ConfigFileFullPath = BepInEx.Paths.ConfigPath + Path.DirectorySeparatorChar + ConfigFileName; @@ -101,6 +102,12 @@ void Awake() { cfg = new ELConfig(Config); + // A biomedata.json reload re-resolves the adventure config's biome names and rebuilds the item + // gating order. Subscribed after the first load, which already ran the three configs in + // dependency order (biomedata, adventuredata, iteminfo), so no immediate call is needed. + BiomeDataManager.OnBiomeDataInitialized += AdventureDataManager.OnBiomeDataChanged; + BiomeDataManager.OnBiomeDataInitialized += GatedItemTypeHelper.OnBiomeDataChanged; + // Set the referenced common logger to the EL specific reference so that common things get logged PrefabCreator.Logger = Logger; InitializeAbilities(); @@ -697,7 +704,6 @@ private static void LoadShardSlotChisels() { } 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); @@ -710,30 +716,16 @@ private static void LoadUnidentifiedItems() { continue; } - 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 { - Rarity = rarity, - IsUnidentified = true, - }); - magicItemComponent.Save(); - pid.Save(); - - ItemConfig unidentifiedIC = new ItemConfig() { - Name = $"$mod_epicloot_{rarity} $mod_epicloot_unidentified_{biome}", - Description = "$mod_epicloot_unidentified_introduce", - }; - - CustomItem custom = new CustomItem(prefab, false, unidentifiedIC); - ItemManager.Instance.AddItem(custom); + CreateUnidentifiedItems(genericPrefab, biome, $"$mod_epicloot_unidentified_{biome}", unidentifiedPrefabNames); + } - unidentifiedPrefabNames.Add(prefabName); - } + // Biomes other mods add, declared in biomedata.json. Only the local file counts here: LoadAssets + // runs once in Awake after the configs, and prefabs cannot be registered when a server pushes + // its biome data later, so biomedata.json has to ship with the modpack. + foreach (BiomeDefinition definition in BiomeDataManager.CustomBiomes) { + string displayName = $"$mod_epicloot_unidentified_prefix " + + $"{BiomeDataManager.GetLocalizationToken(definition.Biome)} $mod_epicloot_unidentified_suffix"; + CreateUnidentifiedItems(genericPrefab, definition.Name, displayName, unidentifiedPrefabNames); } // Enable items once things are working so that ZNet issues don't happen. @@ -755,6 +747,36 @@ void EnableUnidentifiedItems() { ItemManager.OnItemsRegistered += EnableUnidentifiedItems; } + // One {biomeName}_{rarity}_Unidentified prefab per rarity. The biome name is the prefix the identify + // flow parses back out (EnchantHelper.GetBiomeFromUnidentifiedItem), so it must not contain '_'. + private static void CreateUnidentifiedItems(GameObject genericPrefab, string biomeName, string biomeDisplayName, + List unidentifiedPrefabNames) { + foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity))) { + var prefab = Object.Instantiate(genericPrefab); + string prefabName = $"{biomeName}_{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 { + Rarity = rarity, + IsUnidentified = true, + }); + magicItemComponent.Save(); + pid.Save(); + + ItemConfig unidentifiedIC = new ItemConfig() { + Name = $"$mod_epicloot_{rarity} {biomeDisplayName}", + Description = "$mod_epicloot_unidentified_introduce", + }; + + CustomItem custom = new CustomItem(prefab, false, unidentifiedIC); + ItemManager.Instance.AddItem(custom); + + unidentifiedPrefabNames.Add(prefabName); + } + } + private static void RegisterStatusEffects() { RegisterBulwark(); RegisterUndying(); diff --git a/EpicLoot/Package/CHANGELOG.md b/EpicLoot/Package/CHANGELOG.md index e6c6aa68..135f75fd 100644 --- a/EpicLoot/Package/CHANGELOG.md +++ b/EpicLoot/Package/CHANGELOG.md @@ -1,3 +1,34 @@ +**0.13.5** + +Changes: +* Custom biome support. Biomes added by other mods (Expand World Data and the like) can be declared in the new `biomedata.json`, which now owns biome progression order, and boss keys + * Every `Biome` field in `adventuredata.json` and every biome key in `enchantcosts.json` takes a biome name (vanilla or from `biomedata.json`) or the numeric biome value + * An unknown biome name is now reported and that entry skipped, instead of the whole config file being thrown away + * Bounties, treasure maps, unidentified items and item gating all read biome order and boss keys from `biomedata.json` + * `Bounties.Bosses` in `adventuredata.json` is deprecated + * A biome with no boss keys is never gated + * Unidentified items are created at startup for every custom biome in the local `biomedata.json` (ship the file with the modpack; a server push alone cannot add prefabs) + * Under `BossKillUnlocksNextBiomeItems`, identifying an item no longer advances past the biome on the item's name + * The identify panel says `(Progress-gated)` when gating will identify a selected item at a lower tier than its biome + * New console commands: `biomes` lists the biome order with keys and status, `knowbiome ` marks a biome discovered so custom-biome bounties can be tested +* `Tempering.CostsByRarity` in `adventuredata.json` now supports removing rarities from tempering entirely + * A rarity left out of the block is no longer temperable at all, and its items are kept out of the temper panel + * A rarity with an empty cost list is still temperable, for free +* Shardstone upgrades moved out of the Convert Materials tab's generic Upgrade list into their own Upgrade Shardstones mode + * That mode only lists an upgrade whose source stone you actually have, instead of every color you hold + * Sacrificing a shardstone now yields 2 dust, 2 reagents and 2 essence of its rarity +* Treasure chests and Bounties now resolve their spawn points much quicker + * Both systems now inherently support custom biomes, world size changes and biome distribution changes + +Bugfixes: +* Adds detection and support for custom item containers (such as backpacks, quivers) + * Hotkeys to open sockets on inventory containing items like this automatically changes +* Partially broken creatures used for bounties no longer crash the bounty system +* Treasure maps added through the API now search for positions like normal +* Compendium group headings are no longer unreadable the first time the compendium is opened in a session +* Fixes shards being disenchtantable + + **0.13.4** Changes: diff --git a/EpicLoot/Package/README.md b/EpicLoot/Package/README.md index 63b4d402..de10e28c 100644 --- a/EpicLoot/Package/README.md +++ b/EpicLoot/Package/README.md @@ -26,7 +26,8 @@ Shardstones grant a variety of small buffs to allow much greater control over yo * Press Interact (E) on a socketed item in your inventory to open its shard slots, then drag in a shardstone or an Etched Runestone. * Enchanted items can now drop with shard slots, and the rarer the item the more slots it is likely to have. * Shardstones drop all over the world, each biome and boss has their own drops. Maybe you can find a unique shardstone also? - * Shardstones can be upgraded to a higher rarity, or converted into ordinary enchanting materials if you don't want them, at the enchanting table's Convert Materials tab. + * Shardstones can be upgraded to a higher rarity in the enchanting table's Convert Materials tab, under its own Upgrade Shardstones mode + * A shardstone you don't want can be sacrificed for dust, reagents and essence of its own rarity Shard drops come from the loot tables, so if you have customized your loottables.json you will need to accept the config update prompt (or refresh BepInEx/config/EpicLoot/baseconfig/loottables.json) before any shardstones will drop. diff --git a/EpicLoot/Package/manifest.json b/EpicLoot/Package/manifest.json index ce869041..b7467e21 100644 --- a/EpicLoot/Package/manifest.json +++ b/EpicLoot/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EpicLoot", - "version_number": "0.13.4", + "version_number": "0.13.5", "website_url": "https://discord.gg/ZNhYeavv3C", "description": "Adds loot drops, magic items, and enchanting to Valheim.", "dependencies": [ diff --git a/EpicLoot/config-patches/EL.examples.enchantcosts.json b/EpicLoot/config-patches/EL.examples.enchantcosts.json index 87866113..6d78fa7e 100644 --- a/EpicLoot/config-patches/EL.examples.enchantcosts.json +++ b/EpicLoot/config-patches/EL.examples.enchantcosts.json @@ -20,6 +20,17 @@ { "Item": "EssenceMythic", "Amount": 1 }, { "Item": "ReagentMythic", "Amount": 1 } ] + }, + { + "IsMagic" : true, + "Rarity" : "Mythic", + "ItemTypes" : [ "Material" ], + "AmmoTypeSuffixes" : [ "Yagluth|Mythic|ShardStone" ], + "Products" : [ + { "Item": "DustMythic", "Amount": 4 }, + { "Item": "EssenceMythic", "Amount": 4 }, + { "Item": "ReagentMythic", "Amount": 4 } + ] } ] } diff --git a/EpicLoot/config-patches/EL.examples.shardstoneconversions.json b/EpicLoot/config-patches/EL.examples.shardstoneconversions.json index 5670c8a3..8c1d9add 100644 --- a/EpicLoot/config-patches/EL.examples.shardstoneconversions.json +++ b/EpicLoot/config-patches/EL.examples.shardstoneconversions.json @@ -19,20 +19,21 @@ "Value": { "Item": "YagluthDrop", "Amount": 2 } }, { - "Path": "$.MaterialConversions[?(@.Name == 'ShardStoneConvert_Red_Mythic_to_Essence')].Amount", + "Path": "$.MaterialConversions[?(@.Name == 'ShardStoneUpgrade_Red_Epic_to_Legendary')].Amount", "Action": "Overwrite", - "Value": 4 + "Value": 2 }, { "Path": "$.MaterialConversions", "Action": "Append", "Value": { - "Name": "ShardStoneConvert_Fader_Mythic_to_Shard", - "Product": "ShardMythic", - "Amount": 3, - "Type": "Convert", + "Name": "ShardStoneUpgrade_Fader_Mythic_to_Mythic", + "Product": "Fader_Mythic_ShardStone", + "Amount": 1, + "Type": "ShardUpgrade", "Resources": [ - { "Item": "Fader_Mythic_ShardStone", "Amount": 1 } + { "Item": "Fader_Mythic_ShardStone", "Amount": 2 }, + { "Item": "ShardMythic", "Amount": 8 } ] } } diff --git a/EpicLoot/config/adventuredata.json b/EpicLoot/config/adventuredata.json index 5e3125bd..e2c5f416 100755 --- a/EpicLoot/config/adventuredata.json +++ b/EpicLoot/config/adventuredata.json @@ -380,16 +380,6 @@ { "Biome": "AshLands", "TargetID": "Charred_Melee", "RewardGold": 5, "RewardIron": 9, "RewardCoins": 650, "Adds": [ { "ID": "Charred_Archer", "Count": 2 }, { "ID": "Charred_Twitcher", "Count": 2 } ] } ], - "Bosses": [ - {"Biome": "Meadows", "BossPrefab": "Eikthyr", "BossDefeatedKey": "defeated_eikthyr" }, - {"Biome": "BlackForest", "BossPrefab": "gd_king", "BossDefeatedKey": "defeated_gdking" }, - {"Biome": "Ocean", "BossPrefab": "gd_king", "BossDefeatedKey": "defeated_gdking" }, - {"Biome": "Swamp", "BossPrefab": "Bonemass", "BossDefeatedKey": "defeated_bonemass" }, - {"Biome": "Mountain", "BossPrefab": "Dragon", "BossDefeatedKey": "defeated_dragon" }, - {"Biome": "Plains", "BossPrefab": "GoblinKing", "BossDefeatedKey": "defeated_goblinking" }, - {"Biome": "Mistlands", "BossPrefab": "SeekerQueen", "BossDefeatedKey": "defeated_queen" }, - {"Biome": "AshLands", "BossPrefab": "Fader", "BossDefeatedKey": "defeated_fader" } - ], "Names": { "ChanceForSpecialName": 0.01, "SpecialNames": [ diff --git a/EpicLoot/config/biomedata.json b/EpicLoot/config/biomedata.json new file mode 100644 index 00000000..ea607ecf --- /dev/null +++ b/EpicLoot/config/biomedata.json @@ -0,0 +1,13 @@ +{ + "Biomes": [ + { "Name": "Meadows", "ID": 1, "Order": 10, "BossDefeatedKeys": [ "defeated_eikthyr" ], "Color": "#75d966" }, + { "Name": "BlackForest", "ID": 8, "Order": 20, "BossDefeatedKeys": [ "defeated_gdking" ], "Color": "#72a178" }, + { "Name": "Ocean", "ID": 256, "Order": 30, "BossDefeatedKeys": [ "defeated_gdking" ], "Color": "#4a90d9" }, + { "Name": "Swamp", "ID": 2, "Order": 40, "BossDefeatedKeys": [ "defeated_bonemass" ], "Color": "#a88a6f" }, + { "Name": "Mountain", "ID": 4, "Order": 50, "BossDefeatedKeys": [ "defeated_dragon" ], "Color": "#a3bcd6" }, + { "Name": "Plains", "ID": 16, "Order": 60, "BossDefeatedKeys": [ "defeated_goblinking" ], "Color": "#d6cea3" }, + { "Name": "Mistlands", "ID": 512, "Order": 70, "BossDefeatedKeys": [ "defeated_queen" ], "Color": "#8b7cb3" }, + { "Name": "AshLands", "ID": 32, "Order": 80, "BossDefeatedKeys": [ "defeated_fader" ], "Color": "#d94a4a" }, + { "Name": "DeepNorth", "ID": 64, "Order": 90, "BossDefeatedKeys": [], "Color": "#a3d6d6" } + ] +} diff --git a/EpicLoot/config/enchantcosts.json b/EpicLoot/config/enchantcosts.json index c233acd7..3052eade 100755 --- a/EpicLoot/config/enchantcosts.json +++ b/EpicLoot/config/enchantcosts.json @@ -937,6 +937,61 @@ "Amount": 1 } ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "AmmoTypeSuffixes": [ "ShardStone" ], + "Rarity": "Magic", + "Products": [ + { "Item": "DustMagic", "Amount": 2 }, + { "Item": "ReagentMagic", "Amount": 2 }, + { "Item": "EssenceMagic", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "AmmoTypeSuffixes": [ "ShardStone" ], + "Rarity": "Rare", + "Products": [ + { "Item": "DustRare", "Amount": 2 }, + { "Item": "ReagentRare", "Amount": 2 }, + { "Item": "EssenceRare", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "AmmoTypeSuffixes": [ "ShardStone" ], + "Rarity": "Epic", + "Products": [ + { "Item": "DustEpic", "Amount": 2 }, + { "Item": "ReagentEpic", "Amount": 2 }, + { "Item": "EssenceEpic", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "AmmoTypeSuffixes": [ "ShardStone" ], + "Rarity": "Legendary", + "Products": [ + { "Item": "DustLegendary", "Amount": 2 }, + { "Item": "ReagentLegendary", "Amount": 2 }, + { "Item": "EssenceLegendary", "Amount": 2 } + ] + }, + { + "IsMagic": true, + "ItemTypes": [ "Material" ], + "AmmoTypeSuffixes": [ "ShardStone" ], + "Rarity": "Mythic", + "Products": [ + { "Item": "DustMythic", "Amount": 2 }, + { "Item": "ReagentMythic", "Amount": 2 }, + { "Item": "EssenceMythic", "Amount": 2 } + ] } ], "EnchantCosts": [ diff --git a/EpicLoot/config/shardstoneconversions.json b/EpicLoot/config/shardstoneconversions.json index c8e69016..385291b6 100644 --- a/EpicLoot/config/shardstoneconversions.json +++ b/EpicLoot/config/shardstoneconversions.json @@ -4,7 +4,7 @@ "Name": "ShardStoneUpgrade_Red_Magic_to_Rare", "Product": "Red_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Red_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -14,7 +14,7 @@ "Name": "ShardStoneUpgrade_Red_Rare_to_Epic", "Product": "Red_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Red_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -24,7 +24,7 @@ "Name": "ShardStoneUpgrade_Red_Epic_to_Legendary", "Product": "Red_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Red_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -34,7 +34,7 @@ "Name": "ShardStoneUpgrade_Red_Legendary_to_Mythic", "Product": "Red_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Red_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -44,7 +44,7 @@ "Name": "ShardStoneUpgrade_Yellow_Magic_to_Rare", "Product": "Yellow_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Yellow_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -54,7 +54,7 @@ "Name": "ShardStoneUpgrade_Yellow_Rare_to_Epic", "Product": "Yellow_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Yellow_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -64,7 +64,7 @@ "Name": "ShardStoneUpgrade_Yellow_Epic_to_Legendary", "Product": "Yellow_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Yellow_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -74,7 +74,7 @@ "Name": "ShardStoneUpgrade_Yellow_Legendary_to_Mythic", "Product": "Yellow_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Yellow_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -84,7 +84,7 @@ "Name": "ShardStoneUpgrade_Cyan_Magic_to_Rare", "Product": "Cyan_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Cyan_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -94,7 +94,7 @@ "Name": "ShardStoneUpgrade_Cyan_Rare_to_Epic", "Product": "Cyan_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Cyan_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -104,7 +104,7 @@ "Name": "ShardStoneUpgrade_Cyan_Epic_to_Legendary", "Product": "Cyan_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Cyan_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -114,7 +114,7 @@ "Name": "ShardStoneUpgrade_Cyan_Legendary_to_Mythic", "Product": "Cyan_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Cyan_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -124,7 +124,7 @@ "Name": "ShardStoneUpgrade_Orange_Magic_to_Rare", "Product": "Orange_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Orange_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -134,7 +134,7 @@ "Name": "ShardStoneUpgrade_Orange_Rare_to_Epic", "Product": "Orange_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Orange_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -144,7 +144,7 @@ "Name": "ShardStoneUpgrade_Orange_Epic_to_Legendary", "Product": "Orange_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Orange_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -154,7 +154,7 @@ "Name": "ShardStoneUpgrade_Orange_Legendary_to_Mythic", "Product": "Orange_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Orange_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -164,7 +164,7 @@ "Name": "ShardStoneUpgrade_Pink_Magic_to_Rare", "Product": "Pink_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Pink_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -174,7 +174,7 @@ "Name": "ShardStoneUpgrade_Pink_Rare_to_Epic", "Product": "Pink_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Pink_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -184,7 +184,7 @@ "Name": "ShardStoneUpgrade_Pink_Epic_to_Legendary", "Product": "Pink_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Pink_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -194,7 +194,7 @@ "Name": "ShardStoneUpgrade_Pink_Legendary_to_Mythic", "Product": "Pink_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Pink_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -204,7 +204,7 @@ "Name": "ShardStoneUpgrade_Black_Magic_to_Rare", "Product": "Black_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Black_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -214,7 +214,7 @@ "Name": "ShardStoneUpgrade_Black_Rare_to_Epic", "Product": "Black_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Black_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -224,7 +224,7 @@ "Name": "ShardStoneUpgrade_Black_Epic_to_Legendary", "Product": "Black_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Black_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -234,7 +234,7 @@ "Name": "ShardStoneUpgrade_Black_Legendary_to_Mythic", "Product": "Black_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Black_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -244,7 +244,7 @@ "Name": "ShardStoneUpgrade_White_Magic_to_Rare", "Product": "White_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "White_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -254,7 +254,7 @@ "Name": "ShardStoneUpgrade_White_Rare_to_Epic", "Product": "White_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "White_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -264,7 +264,7 @@ "Name": "ShardStoneUpgrade_White_Epic_to_Legendary", "Product": "White_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "White_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -274,7 +274,7 @@ "Name": "ShardStoneUpgrade_White_Legendary_to_Mythic", "Product": "White_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "White_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -284,7 +284,7 @@ "Name": "ShardStoneUpgrade_Green_Magic_to_Rare", "Product": "Green_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Green_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -294,7 +294,7 @@ "Name": "ShardStoneUpgrade_Green_Rare_to_Epic", "Product": "Green_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Green_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -304,7 +304,7 @@ "Name": "ShardStoneUpgrade_Green_Epic_to_Legendary", "Product": "Green_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Green_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -314,7 +314,7 @@ "Name": "ShardStoneUpgrade_Green_Legendary_to_Mythic", "Product": "Green_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Green_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -324,7 +324,7 @@ "Name": "ShardStoneUpgrade_Purple_Magic_to_Rare", "Product": "Purple_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Purple_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -334,7 +334,7 @@ "Name": "ShardStoneUpgrade_Purple_Rare_to_Epic", "Product": "Purple_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Purple_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -344,7 +344,7 @@ "Name": "ShardStoneUpgrade_Purple_Epic_to_Legendary", "Product": "Purple_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Purple_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -354,7 +354,7 @@ "Name": "ShardStoneUpgrade_Purple_Legendary_to_Mythic", "Product": "Purple_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Purple_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -364,7 +364,7 @@ "Name": "ShardStoneUpgrade_Grey_Magic_to_Rare", "Product": "Grey_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Grey_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -374,7 +374,7 @@ "Name": "ShardStoneUpgrade_Grey_Rare_to_Epic", "Product": "Grey_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Grey_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -384,7 +384,7 @@ "Name": "ShardStoneUpgrade_Grey_Epic_to_Legendary", "Product": "Grey_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Grey_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -394,7 +394,7 @@ "Name": "ShardStoneUpgrade_Grey_Legendary_to_Mythic", "Product": "Grey_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Grey_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -404,7 +404,7 @@ "Name": "ShardStoneUpgrade_DarkRed_Magic_to_Rare", "Product": "DarkRed_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkRed_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -414,7 +414,7 @@ "Name": "ShardStoneUpgrade_DarkRed_Rare_to_Epic", "Product": "DarkRed_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkRed_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -424,7 +424,7 @@ "Name": "ShardStoneUpgrade_DarkRed_Epic_to_Legendary", "Product": "DarkRed_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkRed_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -434,7 +434,7 @@ "Name": "ShardStoneUpgrade_DarkRed_Legendary_to_Mythic", "Product": "DarkRed_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkRed_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -444,7 +444,7 @@ "Name": "ShardStoneUpgrade_DarkGreen_Magic_to_Rare", "Product": "DarkGreen_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkGreen_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -454,7 +454,7 @@ "Name": "ShardStoneUpgrade_DarkGreen_Rare_to_Epic", "Product": "DarkGreen_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkGreen_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -464,7 +464,7 @@ "Name": "ShardStoneUpgrade_DarkGreen_Epic_to_Legendary", "Product": "DarkGreen_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkGreen_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -474,7 +474,7 @@ "Name": "ShardStoneUpgrade_DarkGreen_Legendary_to_Mythic", "Product": "DarkGreen_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkGreen_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -484,7 +484,7 @@ "Name": "ShardStoneUpgrade_DarkBlue_Magic_to_Rare", "Product": "DarkBlue_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkBlue_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -494,7 +494,7 @@ "Name": "ShardStoneUpgrade_DarkBlue_Rare_to_Epic", "Product": "DarkBlue_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkBlue_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -504,7 +504,7 @@ "Name": "ShardStoneUpgrade_DarkBlue_Epic_to_Legendary", "Product": "DarkBlue_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkBlue_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -514,7 +514,7 @@ "Name": "ShardStoneUpgrade_DarkBlue_Legendary_to_Mythic", "Product": "DarkBlue_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkBlue_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -524,7 +524,7 @@ "Name": "ShardStoneUpgrade_DarkPurple_Magic_to_Rare", "Product": "DarkPurple_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkPurple_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -534,7 +534,7 @@ "Name": "ShardStoneUpgrade_DarkPurple_Rare_to_Epic", "Product": "DarkPurple_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkPurple_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -544,7 +544,7 @@ "Name": "ShardStoneUpgrade_DarkPurple_Epic_to_Legendary", "Product": "DarkPurple_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkPurple_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -554,7 +554,7 @@ "Name": "ShardStoneUpgrade_DarkPurple_Legendary_to_Mythic", "Product": "DarkPurple_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "DarkPurple_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -564,7 +564,7 @@ "Name": "ShardStoneUpgrade_Golden_Magic_to_Rare", "Product": "Golden_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Golden_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -574,7 +574,7 @@ "Name": "ShardStoneUpgrade_Golden_Rare_to_Epic", "Product": "Golden_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Golden_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -584,7 +584,7 @@ "Name": "ShardStoneUpgrade_Golden_Epic_to_Legendary", "Product": "Golden_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Golden_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -594,7 +594,7 @@ "Name": "ShardStoneUpgrade_Golden_Legendary_to_Mythic", "Product": "Golden_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Golden_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -604,7 +604,7 @@ "Name": "ShardStoneUpgrade_LightBlue_Magic_to_Rare", "Product": "LightBlue_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightBlue_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -614,7 +614,7 @@ "Name": "ShardStoneUpgrade_LightBlue_Rare_to_Epic", "Product": "LightBlue_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightBlue_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -624,7 +624,7 @@ "Name": "ShardStoneUpgrade_LightBlue_Epic_to_Legendary", "Product": "LightBlue_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightBlue_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -634,7 +634,7 @@ "Name": "ShardStoneUpgrade_LightBlue_Legendary_to_Mythic", "Product": "LightBlue_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightBlue_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -644,7 +644,7 @@ "Name": "ShardStoneUpgrade_LightGreen_Magic_to_Rare", "Product": "LightGreen_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightGreen_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -654,7 +654,7 @@ "Name": "ShardStoneUpgrade_LightGreen_Rare_to_Epic", "Product": "LightGreen_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightGreen_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -664,7 +664,7 @@ "Name": "ShardStoneUpgrade_LightGreen_Epic_to_Legendary", "Product": "LightGreen_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightGreen_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -674,7 +674,7 @@ "Name": "ShardStoneUpgrade_LightGreen_Legendary_to_Mythic", "Product": "LightGreen_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "LightGreen_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -684,7 +684,7 @@ "Name": "ShardStoneUpgrade_Peach_Magic_to_Rare", "Product": "Peach_Rare_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Peach_Magic_ShardStone", "Amount": 1 }, { "Item": "ShardMagic", "Amount": 4 } @@ -694,7 +694,7 @@ "Name": "ShardStoneUpgrade_Peach_Rare_to_Epic", "Product": "Peach_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Peach_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 } @@ -704,7 +704,7 @@ "Name": "ShardStoneUpgrade_Peach_Epic_to_Legendary", "Product": "Peach_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Peach_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 } @@ -714,7 +714,7 @@ "Name": "ShardStoneUpgrade_Peach_Legendary_to_Mythic", "Product": "Peach_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Peach_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 } @@ -724,7 +724,7 @@ "Name": "ShardStoneUpgrade_Firewalker_Epic_to_Legendary", "Product": "Firewalker_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Firewalker_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 }, @@ -735,7 +735,7 @@ "Name": "ShardStoneUpgrade_Firewalker_Legendary_to_Mythic", "Product": "Firewalker_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Firewalker_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -746,7 +746,7 @@ "Name": "ShardStoneUpgrade_Stormcaller_Epic_to_Legendary", "Product": "Stormcaller_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Stormcaller_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 }, @@ -757,7 +757,7 @@ "Name": "ShardStoneUpgrade_Stormcaller_Legendary_to_Mythic", "Product": "Stormcaller_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Stormcaller_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -768,7 +768,7 @@ "Name": "ShardStoneUpgrade_Eikthyr_Rare_to_Epic", "Product": "Eikthyr_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Eikthyr_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 }, @@ -779,7 +779,7 @@ "Name": "ShardStoneUpgrade_Eikthyr_Epic_to_Legendary", "Product": "Eikthyr_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Eikthyr_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 }, @@ -790,7 +790,7 @@ "Name": "ShardStoneUpgrade_Eikthyr_Legendary_to_Mythic", "Product": "Eikthyr_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Eikthyr_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -801,7 +801,7 @@ "Name": "ShardStoneUpgrade_Elder_Rare_to_Epic", "Product": "Elder_Epic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Elder_Rare_ShardStone", "Amount": 1 }, { "Item": "ShardRare", "Amount": 5 }, @@ -812,7 +812,7 @@ "Name": "ShardStoneUpgrade_Elder_Epic_to_Legendary", "Product": "Elder_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Elder_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 }, @@ -823,7 +823,7 @@ "Name": "ShardStoneUpgrade_Elder_Legendary_to_Mythic", "Product": "Elder_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Elder_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -834,7 +834,7 @@ "Name": "ShardStoneUpgrade_Bonemass_Epic_to_Legendary", "Product": "Bonemass_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Bonemass_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 }, @@ -845,7 +845,7 @@ "Name": "ShardStoneUpgrade_Bonemass_Legendary_to_Mythic", "Product": "Bonemass_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Bonemass_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -856,7 +856,7 @@ "Name": "ShardStoneUpgrade_Moder_Epic_to_Legendary", "Product": "Moder_Legendary_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Moder_Epic_ShardStone", "Amount": 1 }, { "Item": "ShardEpic", "Amount": 6 }, @@ -867,7 +867,7 @@ "Name": "ShardStoneUpgrade_Moder_Legendary_to_Mythic", "Product": "Moder_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Moder_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -878,7 +878,7 @@ "Name": "ShardStoneUpgrade_Yagluth_Legendary_to_Mythic", "Product": "Yagluth_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "Resources": [ { "Item": "Yagluth_Legendary_ShardStone", "Amount": 1 }, { "Item": "ShardLegendary", "Amount": 7 }, @@ -889,3117 +889,12 @@ "Name": "ShardStoneUpgrade_Queen_Legendary_to_Mythic", "Product": "Queen_Mythic_ShardStone", "Amount": 1, - "Type": "Upgrade", + "Type": "ShardUpgrade", "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/localizations/English.json b/EpicLoot/localizations/English.json index 098fd4a8..819b3535 100644 --- a/EpicLoot/localizations/English.json +++ b/EpicLoot/localizations/English.json @@ -718,6 +718,8 @@ "mod_epicloot_unidentified_Mountain": "Unidentified Mountain Gear", "mod_epicloot_unidentified_Ocean": "Unidentified Ocean Gear", "mod_epicloot_unidentified_DeepNorth": "Unidentified DeepNorth Gear", + "mod_epicloot_unidentified_prefix": "Unidentified", + "mod_epicloot_unidentified_suffix": "Gear", "mod_epicloot_unidentified_introduce": "This item is unidentified, it could be anything or it could just be ground up into dust! Go to the enchanting table to identify it.", "mod_epicloot_tab_sacrifice": "Sacrifice", "mod_epicloot_tab_identify": "Identify", @@ -731,6 +733,7 @@ "mod_epicloot_identify_accessory": "Accessory", "mod_epicloot_identify_explain": "Identify a magic item to turn it into equipment. You can filter the category for a cost.", "mod_epicloot_identify_productsexplainer": "Potential Items Identified", + "mod_epicloot_identify_progressgated": "(Progress-gated)", "mod_epicloot_identify": "Identify", @@ -797,6 +800,10 @@ "mod_epicloot_junkdesc": "Salvage Junk", "mod_epicloot_junkprogress": "Salvaging items from junk...", "mod_epicloot_junkcost": "Salvage Cost:", + "mod_epicloot_shardupgrade": "Upgrade", + "mod_epicloot_shardupgradedesc": "Upgrade Shardstones", + "mod_epicloot_shardupgradeprogress": "Upgrading shardstones...", + "mod_epicloot_shardupgradecost": "Upgrade Cost:", "mod_epicloot_disenchant": "Disenchant", "mod_epicloot_disenchantprogress": "Disenchanting...", @@ -819,7 +826,7 @@ "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 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_convertmaterials": "Upgrade enchanting materials to higher rarity, convert shards to enchanting materials of equal rarity, salvage other materials from junk, and upgrade shardstones to higher rarity.\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.", "mod_epicloot_featureinfo_disenchant": "Spend materials to remove the magic effects from an item, returning it to its non-magical state.\n\nWhen upgraded, gain a chance to recover sacrifice materials when disenchanting (not affected by Sacrifice feature level) and decrease the cost of disenchanting.", @@ -885,6 +892,8 @@ "mod_epicloot_itemtooltip_rarity": "Rarity", "mod_epicloot_itemtooltip_effects": "Effects", + "mod_epicloot_adventure_locatefailed": "Could not find a suitable location in the $1.", + "mod_epicloot_adventure_locateunavailable": "Still mapping the world - try again in a moment.", "mod_epicloot_adventure_spawnrelocated": "Your map has been redrawn - the marked area was occupied.", "mod_epicloot_bounties_claimedmsg": "Bounty Claimed!", diff --git a/EpicLoot/src/Adventure/AdventureCacheDriver.cs b/EpicLoot/src/Adventure/AdventureCacheDriver.cs new file mode 100644 index 00000000..cda102bc --- /dev/null +++ b/EpicLoot/src/Adventure/AdventureCacheDriver.cs @@ -0,0 +1,41 @@ +using System.Collections; +using UnityEngine; + +namespace EpicLoot.Adventure +{ + /// + /// Coroutine host for adventure work that must outlive the UI that started it. + /// + /// The merchant panel cannot run these -- StoreGui deactivates that object on close, which would + /// stop a coroutine partway -- and Player.m_localPlayer is no better: a coroutine hosted there + /// dies with the player object on death, logout or world change, leaving the caller's completion + /// callback to never run at all. + /// + /// This object is DontDestroyOnLoad, so the only thing that stops its coroutines is + /// , which BountyLocationEarlyCache.Reset calls on world change. + /// + internal class AdventureCacheDriver : MonoBehaviour + { + private static AdventureCacheDriver _instance; + + public static void Run(IEnumerator routine) + { + if (_instance == null) + { + var go = new GameObject("EpicLoot_AdventureCacheDriver"); + UnityEngine.Object.DontDestroyOnLoad(go); + _instance = go.AddComponent(); + } + + _instance.StartCoroutine(routine); + } + + public static void StopAll() + { + if (_instance != null) + { + _instance.StopAllCoroutines(); + } + } + } +} diff --git a/EpicLoot/src/Adventure/AdventureDataConfig.cs b/EpicLoot/src/Adventure/AdventureDataConfig.cs index 5fb98e8e..1daf8e46 100644 --- a/EpicLoot/src/Adventure/AdventureDataConfig.cs +++ b/EpicLoot/src/Adventure/AdventureDataConfig.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using EpicLoot.Biomes; using EpicLoot.Crafting; namespace EpicLoot.Adventure @@ -64,7 +65,8 @@ public class GambleConfig [Serializable] public class TreasureMapBiomeInfoConfig { - public Heightmap.Biome Biome; + /// A biome name (vanilla or biomedata.json) or numeric value, resolved through the registry on use. + public string Biome; public int Cost; public int ForestTokens = 0; public int GoldTokens; @@ -72,6 +74,8 @@ public class TreasureMapBiomeInfoConfig public int Coins; public float MinRadius; public float MaxRadius; + + public Heightmap.Biome GetBiome() => BiomeDataManager.Resolve(Biome); } [Serializable] @@ -94,6 +98,19 @@ public class TreasureMapConfig /// restore the old behaviour of never searching outside the circle. /// public int MaxSpawnSearchExpansions = 5; + + /// + /// Scale every biome's MinRadius/MaxRadius by the real world radius / 10000, so the shipped + /// bands keep their meaning on a world resized by Expand World Size. Without this, AshLands' + /// 8000-10500 band sits in the inner Meadows of a 40km map and no AshLands point is ever in + /// band. Set false if the bands have already been retuned in metres for the target world, + /// otherwise the scaling is applied twice. + /// + /// The `= true` default is load-bearing: an existing on-disk adventuredata.json has no such + /// key, and Newtonsoft leaves an absent field at its initializer. + /// + public bool ScaleRadiiToWorldSize = true; + public List SaleItems = new List(); [NonSerialized] @@ -103,15 +120,24 @@ public Heightmap.Biome[] GetBiomeList() { if (_biomeList == null) { - _biomeList = BiomeInfo.Select(item => item.Biome).ToArray(); + UpdateBiomeList(); } return _biomeList; } + /// + /// Re-resolves the BiomeInfo biome names. Runs on every adventure data load, whenever + /// biomedata.json reloads, and when the API appends a treasure map. A name that does not + /// resolve is left out rather than filed under None. + /// public void UpdateBiomeList() { - _biomeList = BiomeInfo.Select(item => item.Biome).ToArray(); + _biomeList = BiomeInfo + .Select(item => item.GetBiome()) + .Where(biome => biome != Heightmap.Biome.None && biome != Heightmap.Biome.All) + .Distinct() + .ToArray(); } } @@ -134,18 +160,25 @@ public class BountyTargetNameConfig [Serializable] public class BountyTargetConfig { - public Heightmap.Biome Biome; + /// A biome name (vanilla or biomedata.json) or numeric value, resolved through the registry on use. + public string Biome; public string TargetID; public int RewardGold; public int RewardIron; public int RewardCoins; public List Adds = new List(); + + public Heightmap.Biome GetBiome() => BiomeDataManager.Resolve(Biome); } + /// + /// Deprecated. Biome progression order and boss keys live in biomedata.json; entries here are + /// still read so older files and patches keep working (see BiomeDataManager.SetLegacyBosses). + /// [Serializable] public class BountyBossConfig { - public Heightmap.Biome Biome; + public string Biome; public string BossPrefab; public string BossDefeatedKey; } @@ -171,9 +204,8 @@ public class BountiesConfig [Serializable] public class TemperingConfig { - // Left empty rather than seeded with the defaults: Newtonsoft APPENDS to pre-initialized - // collections, and an absent rarity key has to fall through to TemperMan's hardcoded - // default instead of merging with it. TemperMan.ApplyConfig owns the fallback. + // There are no hardcoded temper costs to seed this with: what the json lists is the whole cost + // table. A rarity omitted here is not temperable at all; see TemperMan.ApplyConfig. public Dictionary> CostsByRarity = new Dictionary>(); } diff --git a/EpicLoot/src/Adventure/AdventureDataManager.cs b/EpicLoot/src/Adventure/AdventureDataManager.cs index 01c71663..d6692f46 100755 --- a/EpicLoot/src/Adventure/AdventureDataManager.cs +++ b/EpicLoot/src/Adventure/AdventureDataManager.cs @@ -1,4 +1,5 @@ using EpicLoot.Adventure.Feature; +using EpicLoot.Biomes; using System; using System.Collections.Generic; using System.Linq; @@ -34,6 +35,11 @@ public static void Initialize(AdventureDataConfig config) // server->client RPC - routes through here, so this is the only hook tempering costs need. TemperMan.ApplyConfig(Config?.Tempering); + // Surface unresolvable biome names once per load, then hand the deprecated Bounties.Bosses + // list to the registry, which appends any biome it does not already define. + ReportUnresolvedBiomes(); + BiomeDataManager.SetLegacyBosses(Config?.Bounties?.Bosses); + OnSetupAdventureData?.Invoke(); SecretStash = new SecretStashAdventureFeature(); @@ -50,12 +56,34 @@ public static AdventureDataConfig GetCFG() return Config; } - public static void UpdateAventureData(AdventureDataConfig config) + /// + /// biomedata.json was (re)loaded: the adventure config's biome names may resolve differently + /// now, so re-check them and rebuild the treasure map biome list. Must not re-send the legacy + /// Bosses list; the registry re-applies what it was last given on its own rebuild. + /// + public static void OnBiomeDataChanged() { - Config = config; + if (Config == null) + { + return; + } - Config.TreasureMap.UpdateBiomeList(); - EpicLoot.Log($"Updated Adventure Data"); + ReportUnresolvedBiomes(); + Config.TreasureMap?.UpdateBiomeList(); + } + + private static void ReportUnresolvedBiomes() + { + // Resolved purely for the side effect: the registry warns once per unknown name per load. + foreach (TreasureMapBiomeInfoConfig info in Config?.TreasureMap?.BiomeInfo ?? new List()) + { + info.GetBiome(); + } + + foreach (BountyTargetConfig target in Config?.Bounties?.Targets ?? new List()) + { + target.GetBiome(); + } } public static Sprite GetTrophyIconForMonster(string monsterID, bool isGold) @@ -73,7 +101,12 @@ public static Sprite GetTrophyIconForMonster(string monsterID, bool isGold) var characterDrop = prefab.GetComponent(); if (characterDrop != null) { - var drops = characterDrop.m_drops.Select(x => x.m_prefab.GetComponent()); + // A drop entry can have no prefab assigned (mod-added creatures do this), and a + // prefab need not carry an ItemDrop. + var drops = characterDrop.m_drops + .Where(x => x.m_prefab != null) + .Select(x => x.m_prefab.GetComponent()) + .Where(x => x != null); var trophyPrefab = drops.FirstOrDefault(x => x.m_itemData.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Trophy); if (trophyPrefab != null) { diff --git a/EpicLoot/src/Adventure/AdventureSaveData.cs b/EpicLoot/src/Adventure/AdventureSaveData.cs index 21c00c56..f7b26728 100755 --- a/EpicLoot/src/Adventure/AdventureSaveData.cs +++ b/EpicLoot/src/Adventure/AdventureSaveData.cs @@ -1,4 +1,5 @@ using Common; +using EpicLoot.Biomes; using System; using System.Collections.Generic; using System.Linq; @@ -282,7 +283,7 @@ public bool PurchasedTreasureMap(TreasureMapChestInfo chestInfo) { Position = chestInfo.Position + chestInfo.MinimapCircleOffset, Type = EpicLoot.TreasureMapPinType, - Name = Localization.instance.Localize("$mod_epicloot_treasurechest_minimappin", Localization.instance.Localize($"$biome_{chestInfo.Biome.ToString().ToLowerInvariant()}"), (chestInfo.Interval + 1).ToString()) + Name = Localization.instance.Localize("$mod_epicloot_treasurechest_minimappin", Localization.instance.Localize(BiomeDataManager.GetLocalizationToken(chestInfo.Biome)), (chestInfo.Interval + 1).ToString()) }; var pinJob = new PinJob @@ -357,7 +358,7 @@ public bool RelocateTreasureMap(int interval, Heightmap.Biome biome, Vector3 new Position = treasureMap.Position + treasureMap.MinimapCircleOffset, Type = EpicLoot.TreasureMapPinType, Name = Localization.instance.Localize("$mod_epicloot_treasurechest_minimappin", - Localization.instance.Localize($"$biome_{biome.ToString().ToLowerInvariant()}"), + Localization.instance.Localize(BiomeDataManager.GetLocalizationToken(biome)), (interval + 1).ToString()) }; diff --git a/EpicLoot/src/Adventure/AdventureWardCheck.cs b/EpicLoot/src/Adventure/AdventureWardCheck.cs index 8ad3c43b..eff889e9 100644 --- a/EpicLoot/src/Adventure/AdventureWardCheck.cs +++ b/EpicLoot/src/Adventure/AdventureWardCheck.cs @@ -4,11 +4,13 @@ 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. + /// + /// Checked once, at placement time, from + /// - the only point in the flow where + /// it can mean anything. Picking the world point is seed-only and loads nothing, so + /// holds no ward from a remote zone and a check there would + /// have approved every location regardless. The placement search is the one that runs with the + /// area genuinely loaded, and it expands its search band when a ward vetoes a candidate. /// internal static class AdventureWardCheck { diff --git a/EpicLoot/src/Adventure/BountyListElement.cs b/EpicLoot/src/Adventure/BountyListElement.cs index 2b8856c0..a91e32fe 100644 --- a/EpicLoot/src/Adventure/BountyListElement.cs +++ b/EpicLoot/src/Adventure/BountyListElement.cs @@ -1,4 +1,5 @@ -using System; +using EpicLoot.Biomes; +using System; using System.Text; using UnityEngine; using UnityEngine.UI; @@ -90,7 +91,7 @@ private string GetDisplayName() private string GetTooltip() { _sb.Clear(); - var biome = $"$biome_{BountyInfo.Biome.ToString().ToLower()}"; + var biome = BiomeDataManager.GetLocalizationToken(BountyInfo.Biome); var monsterName = AdventureDataManager.GetMonsterName(BountyInfo.Target.MonsterID).ToLowerInvariant(); var targetName = string.IsNullOrEmpty(BountyInfo.TargetName) ? "" : $"{BountyInfo.TargetName}, "; var slayMessage = BountyInfo.Adds.Count > 0 ? "$mod_epicloot_bounties_tooltip_slaymultiple" : "$mod_epicloot_bounties_tooltip_slay"; diff --git a/EpicLoot/src/Adventure/MerchantPanel.cs b/EpicLoot/src/Adventure/MerchantPanel.cs index 380eae7d..96e6d188 100755 --- a/EpicLoot/src/Adventure/MerchantPanel.cs +++ b/EpicLoot/src/Adventure/MerchantPanel.cs @@ -196,7 +196,10 @@ public void Awake() // Top up the cache of bounty positions. Runs on the cache's own driver rather than this // panel: StoreGui deactivates the panel on close, which would abandon the fill partway. // It is a no-op once every biome is stocked, so reopening the merchant costs nothing. - BountyLocationEarlyCache.RequestRefill(ignoreThreshold: true); + // Warm the world biome index while the player browses, so accepting a bounty resolves + // instantly instead of waiting on a first build. Cheap to call repeatedly: it returns at + // once when the index is current, and rebuilds only if the world or its size changed. + WorldBiomeIndex.EnsureBuilt(); if (EpicLoot.HasAuga) { diff --git a/EpicLoot/src/Adventure/Minimap/MinimapController.cs b/EpicLoot/src/Adventure/Minimap/MinimapController.cs index e9ee8fab..594c5c77 100644 --- a/EpicLoot/src/Adventure/Minimap/MinimapController.cs +++ b/EpicLoot/src/Adventure/Minimap/MinimapController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using EpicLoot.Biomes; using UnityEngine; using UnityEngine.UI; @@ -253,7 +254,7 @@ private static void RefreshTreasureMaps(bool show) Position = chestInfo.Position + chestInfo.MinimapCircleOffset, Type = EpicLoot.TreasureMapPinType, Name = Localization.instance.Localize("$mod_epicloot_treasurechest_minimappin", - Localization.instance.Localize($"$biome_{chestInfo.Biome.ToString().ToLowerInvariant()}"), + Localization.instance.Localize(BiomeDataManager.GetLocalizationToken(chestInfo.Biome)), (chestInfo.Interval + 1).ToString()) }; diff --git a/EpicLoot/src/Adventure/TreasureMapChest.cs b/EpicLoot/src/Adventure/TreasureMapChest.cs index 4f4dddef..ad6fff14 100755 --- a/EpicLoot/src/Adventure/TreasureMapChest.cs +++ b/EpicLoot/src/Adventure/TreasureMapChest.cs @@ -1,4 +1,5 @@ -using HarmonyLib; +using EpicLoot.Biomes; +using HarmonyLib; using System; using UnityEngine; using Object = UnityEngine.Object; @@ -10,7 +11,7 @@ public class TreasureMapChest : MonoBehaviour public Heightmap.Biome Biome; public int Interval; - public string LootTableName => $"TreasureMapChest_{Biome}"; + public string LootTableName => $"TreasureMapChest_{BiomeDataManager.GetName(Biome)}"; public void Setup(long playerID, Heightmap.Biome biome, int treasureMapInterval) { @@ -35,7 +36,7 @@ public void Setup(long playerID, Heightmap.Biome biome, int treasureMapInterval) var items = LootRoller.RollLootTable(LootTableName, 1, LootTableName, transform.position); items.ForEach(item => container.m_inventory.AddItem(item)); - var biomeConfig = AdventureDataManager.Config.TreasureMap.BiomeInfo.Find(x => x.Biome == biome); + var biomeConfig = AdventureDataManager.Config.TreasureMap.BiomeInfo.Find(x => x.GetBiome() == biome); if (biomeConfig?.ForestTokens > 0) container.m_inventory.AddItem("ForestToken", biomeConfig.ForestTokens, 1, 0, 0, string.Empty); @@ -66,7 +67,7 @@ public void Reinitialize(Heightmap.Biome biome, int treasureMapInterval, bool ha if (container != null) { var label = Localization.instance.Localize("$mod_epicloot_treasurechest_name", - $"$biome_{Biome.ToString().ToLower()}", (treasureMapInterval + 1).ToString()); + BiomeDataManager.GetLocalizationToken(Biome), (treasureMapInterval + 1).ToString()); container.m_name = Localization.instance.Localize(label); container.m_privacy = hasBeenFound ? Container.PrivacySetting.Public : Container.PrivacySetting.Private; container.m_autoDestroyEmpty = true; @@ -118,7 +119,9 @@ public static void Postfix(Container __instance) var biomeString = zdo.GetString($"{nameof(TreasureMapChest)}.{nameof(TreasureMapChest.Biome)}"); if (!string.IsNullOrEmpty(biomeString)) { - if (Enum.TryParse(biomeString, out Heightmap.Biome biome)) + // The ZDO holds Biome.ToString(): an enum name for vanilla biomes, the number for a + // custom one. The registry reads both. + if (BiomeDataManager.TryResolve(biomeString, out Heightmap.Biome biome)) { var interval = zdo.GetInt("TreasureMapChest.Interval"); var hasBeenFound = zdo.GetBool("TreasureMapChest.HasBeenFound"); diff --git a/EpicLoot/src/Adventure/TreasureMapListElement.cs b/EpicLoot/src/Adventure/TreasureMapListElement.cs index 1da04e0c..61a01ba7 100644 --- a/EpicLoot/src/Adventure/TreasureMapListElement.cs +++ b/EpicLoot/src/Adventure/TreasureMapListElement.cs @@ -1,4 +1,5 @@ using EpicLoot.Adventure.Feature; +using EpicLoot.Biomes; using System; using UnityEngine; using UnityEngine.UI; @@ -50,7 +51,7 @@ public void SetItem(TreasureMapItemInfo itemInfo, int currentCoins) CanAfford = Price <= currentCoins || Player.m_localPlayer.NoCostCheat(); AlreadyPurchased = itemInfo.AlreadyPurchased; - var displayName = Localization.instance.Localize("$mod_epicloot_treasuremap_name", $"$biome_{Biome.ToString().ToLower()}", (itemInfo.Interval + 1).ToString()); + var displayName = Localization.instance.Localize("$mod_epicloot_treasuremap_name", BiomeDataManager.GetLocalizationToken(Biome), (itemInfo.Interval + 1).ToString()); Icon.color = (CanAfford && !AlreadyPurchased) ? Color.white : new Color(1.0f, 0.0f, 1.0f, 0.0f); NameText.text = Localization.instance.Localize(displayName); @@ -74,7 +75,7 @@ public void SetItem(TreasureMapItemInfo itemInfo, int currentCoins) private string GetTooltip() { - var biome = $"$biome_{Biome.ToString().ToLower()}"; + var biome = BiomeDataManager.GetLocalizationToken(Biome); return Localization.instance.Localize("$mod_epicloot_treasuremap_tooltip", biome); } } diff --git a/EpicLoot/src/Adventure/WorldBiomeIndex.cs b/EpicLoot/src/Adventure/WorldBiomeIndex.cs new file mode 100644 index 00000000..e3dc50e0 --- /dev/null +++ b/EpicLoot/src/Adventure/WorldBiomeIndex.cs @@ -0,0 +1,953 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using EpicLoot.Biomes; +using UnityEngine; + +namespace EpicLoot.Adventure +{ + internal enum BiomeIndexState + { + NotBuilt, + Building, + Ready, + Unavailable + } + + /// + /// A coarse map of where each biome is, sampled straight from the world seed. + /// + /// This exists because picking a bounty or treasure location used to be rejection sampling: throw + /// a random dart at the world, force-load the zone it landed in to see what was there, throw it + /// away, repeat. That cost a zone instantiate and a terrain build per dart, and for a biome the + /// darts could not reach (AshLands sits in a polar cap, not scattered across the disc) it never + /// terminated usefully at all. + /// + /// WorldGenerator.GetBiome is a pure function of the world seed and an x/z pair -- no zone, no + /// Heightmap, no Random, and vanilla already calls it off the main thread from + /// HeightmapBuilder.BuildThread. So the whole world can be sampled once, up front, for less than + /// the cost of a handful of the old darts, and every later request becomes a lookup. + /// + /// Buckets are keyed by whatever Heightmap.Biome value GetBiome returned, never by a name or a + /// hardcoded list. DeepNorth (which has no adventure config entry) and biomes another mod adds + /// therefore work with no code change here. + /// + internal static class WorldBiomeIndex + { + /// + /// Cells of one biome, sorted by distance from the world centre. The sort is what turns a + /// "cells within this radius band" query into a binary-search slice instead of a full scan. + /// + private sealed class BiomeBucket + { + public float[] Radius; + public float[] X; + public float[] Z; + public int Count => Radius.Length; + } + + private static readonly Dictionary Buckets = new(); + + private static BiomeIndexState _state = BiomeIndexState.NotBuilt; + + // Validity stamp. The seed alone is not enough: Expand World Size can resize the world + // mid-session (a server sync or a cfg hot reload, both of which regenerate the world) without + // the seed ever changing, and every cached point would then be measured against the old map. + private static int _seed; + private static float _stampPlayable; + private static float _stampTotal; + private static float _stampStretch; + + private static float _cellSize = DefaultCellSize; + private static int _cellCount; + private static double _buildMs; + private static int _buildFrames; + + /// + /// Sample spacing on a vanilla world: two zones wide, and biome selection noise runs at a + /// 1/1000 scale so real regions are hundreds of metres across. + /// + private const float DefaultCellSize = 128f; + + /// Ceiling on total samples, so an enormous world cannot make the build unbounded. + private const int MaxCells = 400000; + + /// Main-thread time the build may take per frame. + private const float FrameBudgetMs = 2f; + + private const int SamplesPerBudgetCheck = 128; + + /// Candidate points examined before a search gives up on one rung of the ladder. + private const int MaxCandidatesPerAttempt = 512; + + /// + /// How many indexed cells a search may try. Breadth matters far more than depth here: a cell + /// centre was sampled as this biome when the index was built, so it is the only candidate + /// guaranteed to still be in the biome, and testing one costs a single height sample. Points + /// jittered away from a centre are worth trying for variety but are the first thing to fail + /// in a fragmented biome, so they must never crowd out cells that have not been looked at. + /// + /// This was 4, with 36 jittered candidates each. In AshLands -- fragmented, and largely lava + /// and below-water trench -- that meant a search covered 8 guaranteed-in-biome points out of + /// thousands available, all of them clustered around one anchor, and reported the biome as + /// unusable when that one neighbourhood happened to be bad. + /// + private const int MaxCellsPerAttempt = 64; + + private const int NearestCellCount = 64; + + /// + /// Cells sampled from across the whole slice once the nearest ones are exhausted. The nearest + /// cells are by construction all in one neighbourhood, so a single hostile region (a lava + /// field, a stretch of trench) rejects all of them together; these are strided across the + /// biome so they cannot fail for the same local reason. + /// + private const int SpreadCellCount = 64; + + /// Jittered points tried around a validated cell centre, purely for variety. + private const int VarietyAttempts = 6; + + /// + /// How many of the nearest cells get shuffled before the search walks them. Enough that two + /// bounties in a row do not resolve to the same cell, small enough that the pick is still + /// recognisably the nearest patch of the biome to the anchor. + /// + private const int VarietyShuffleCount = 8; + + /// + /// Minimum spacing between consecutive points handed out for the same biome, when the K + /// nearest cells offer a choice. Stops two bounties in a row landing in the same clearing. + /// + private const float PreferredSpacing = 200f; + + /// + /// Biomes whose terrain is below the water line essentially everywhere, measured from this + /// world rather than assumed. See . + /// + private static readonly HashSet OpenWaterBiomes = new(); + + /// Cells sampled per biome when deciding whether it is open water. + private const int OpenWaterSampleCount = 128; + + /// + /// Above-water samples that disqualify a biome from being open water. Two rather than one so + /// a single freak sandbar in an ocean does not flip the answer, and the sampler stops as soon + /// as it is exceeded -- so a land biome costs three height samples, not 128. + /// + private const int OpenWaterMaxAboveWaterSamples = 2; + + private static readonly Dictionary LastIssued = new(); + + // Scratch for the nearest-cell search. Reused rather than allocated per query: searching is + // main-thread only and never re-enters itself, so there is exactly one live search at a time. + private static readonly int[] NearestScratch = new int[NearestCellCount]; + private static readonly float[] NearestDistanceScratch = new float[NearestCellCount]; + + internal static BiomeIndexState State => _state; + internal static float CellSize => _cellSize; + internal static int CellCount => _cellCount; + + /// + /// Builds the index if it is missing or stale. Safe and cheap to call repeatedly -- the + /// merchant panel calls it on open and every spawn point request calls it again, which is + /// what makes a mid-session world resize self-correcting without subscribing to anything. + /// + internal static void EnsureBuilt() + { + if (_state == BiomeIndexState.Building) + { + return; + } + + WorldExtent.Refresh(); + + if (!PreconditionsMet()) + { + // Not in a world yet. Stay retryable rather than latching Unavailable. + _state = BiomeIndexState.NotBuilt; + return; + } + + if (_state == BiomeIndexState.Ready && StampMatches()) + { + return; + } + + _state = BiomeIndexState.Building; + AdventureCacheDriver.Run(BuildRoutine()); + } + + /// + /// Drops the index. Called on world change -- every cell is a world position, and carrying + /// them into another world would hand out locations from the wrong map. + /// + internal static void Reset() + { + WorldExtent.InvalidateProbe(); + Buckets.Clear(); + OpenWaterBiomes.Clear(); + LastIssued.Clear(); + _state = BiomeIndexState.NotBuilt; + _seed = 0; + _stampPlayable = 0f; + _stampTotal = 0f; + _stampStretch = 0f; + _cellCount = 0; + _buildMs = 0; + _buildFrames = 0; + } + + private static bool PreconditionsMet() + { + // ZNet is the menu-world guard: FejdStartup initializes a WorldGenerator for the menu + // world before ZNet exists, and that generator only ever reports Mountain or BlackForest. + // Player gates out a dedicated server, which has no reason to build this. + return WorldGenerator.instance != null && + ZNet.instance != null && + ZoneSystem.instance != null && + Player.m_localPlayer != null; + } + + private static bool StampMatches() + { + return _seed == WorldGenerator.instance.GetSeed() && + Mathf.Approximately(_stampPlayable, WorldExtent.PlayableRadius) && + Mathf.Approximately(_stampTotal, WorldExtent.TotalRadius) && + Mathf.Approximately(_stampStretch, WorldExtent.Stretch); + } + + /// + /// Sample spacing for the current world. Feature size tracks stretch, not radius: doubling + /// the world radius at stretch 1 produces twice as many patches of the same size, not bigger + /// ones, so the grid must stay fine. The budget term is what stops that from turning a very + /// large world into an unbounded build. + /// + private static float ResolveCellSize() + { + float byFeature = DefaultCellSize * Mathf.Clamp(WorldExtent.Stretch, 0.5f, 8f); + float byBudget = Mathf.Sqrt(Mathf.PI * WorldExtent.TotalRadius * WorldExtent.TotalRadius / MaxCells); + return Mathf.Max(byFeature, byBudget); + } + + private static IEnumerator BuildRoutine() + { + var scratch = new Dictionary>(); + var stopwatch = Stopwatch.StartNew(); + double elapsedMs = 0; + int frames = 0; + int sampled = 0; + bool failed = false; + + float radius = WorldExtent.TotalRadius; + float cellSize = ResolveCellSize(); + var worldGenerator = WorldGenerator.instance; + int steps = Mathf.CeilToInt(radius * 2f / cellSize); + float radiusSq = radius * radius; + + for (int iz = 0; iz < steps; iz++) + { + float z = -radius + (iz + 0.5f) * cellSize; + + for (int ix = 0; ix < steps; ix++) + { + float x = -radius + (ix + 0.5f) * cellSize; + if (x * x + z * z > radiusSq) + { + continue; + } + + Heightmap.Biome biome; + try + { + biome = worldGenerator.GetBiome(x, z); + } + catch (Exception e) + { + EpicLoot.LogErrorForce("Failed while sampling the world biome index at " + + $"({x:0}, {z:0}): {e}"); + failed = true; + break; + } + + sampled++; + + // Checked before the Biome.None skip below, so a long run of skipped samples + // cannot starve the yield and freeze the frame. + if (sampled % SamplesPerBudgetCheck == 0 && stopwatch.Elapsed.TotalMilliseconds >= FrameBudgetMs) + { + elapsedMs += stopwatch.Elapsed.TotalMilliseconds; + frames++; + // Deliberately not WaitForEndOfFrame: a minimised or occluded window can stop + // producing end-of-frame, which is how the old picker could hang forever. + yield return null; + stopwatch.Reset(); + stopwatch.Start(); + } + + if (biome == Heightmap.Biome.None) + { + continue; + } + + if (!scratch.TryGetValue(biome, out var cells)) + { + cells = new List(); + scratch.Add(biome, cells); + } + + cells.Add(new Vector2(x, z)); + } + + if (failed) + { + break; + } + } + + elapsedMs += stopwatch.Elapsed.TotalMilliseconds; + stopwatch.Stop(); + + if (failed) + { + Buckets.Clear(); + _state = BiomeIndexState.Unavailable; + yield break; + } + + yield return null; + frames++; + + Buckets.Clear(); + foreach (var pair in scratch) + { + Buckets.Add(pair.Key, BuildBucket(pair.Value)); + } + + yield return null; + frames++; + + OpenWaterBiomes.Clear(); + foreach (var pair in Buckets) + { + if (IsBucketOpenWater(worldGenerator, pair.Key, pair.Value)) + { + OpenWaterBiomes.Add(pair.Key); + } + } + + _cellSize = cellSize; + _cellCount = sampled; + _buildMs = elapsedMs; + _buildFrames = frames; + _seed = WorldGenerator.instance.GetSeed(); + _stampPlayable = WorldExtent.PlayableRadius; + _stampTotal = WorldExtent.TotalRadius; + _stampStretch = WorldExtent.Stretch; + _state = BiomeIndexState.Ready; + + EpicLoot.LogForce($"World biome index built: {_cellCount} cells, {_cellSize:0.#}m grid, " + + $"{_buildMs:0}ms over {_buildFrames} frames, {WorldExtent.Describe()}. {DescribeBuckets()}"); + } + + /// + /// Decides whether a biome is open water by measuring it, not by naming it. + /// + /// The submerged-point rejection in has to be skipped for a biome + /// that is under water by definition, or it rejects every candidate there and the biome can + /// never produce a spawn point at all -- the exact failure that kept vanilla Ocean bounties + /// from ever placing. Testing biome == Ocean would re-introduce that for any ocean-like + /// biome another mod adds, and the biome registry carries no is-water trait to consult, so + /// this samples the world instead. That also means a modded world where, say, Ocean has been + /// re-shaped into something walkable gets the right answer rather than the assumed one. + /// + /// Sampling strides the radius-sorted bucket so the whole radial range is covered, and stops + /// as soon as the biome has proven it has land. + /// + private static bool IsBucketOpenWater(WorldGenerator worldGenerator, Heightmap.Biome biome, + BiomeBucket bucket) + { + if (bucket.Count == 0) + { + return false; + } + + float waterLevel = ZoneSystem.instance.m_waterLevel; + int samples = Mathf.Min(OpenWaterSampleCount, bucket.Count); + + // Never let the allowance reach the sample count, or a biome with only one or two cells + // -- all of them dry land -- would be called open water simply because it cannot produce + // enough above-water samples to exceed the allowance. + int maxAboveWater = Mathf.Min(OpenWaterMaxAboveWaterSamples, samples - 1); + int aboveWater = 0; + + for (int i = 0; i < samples; i++) + { + int index = (int)((long)i * bucket.Count / samples); + float height = worldGenerator.GetBiomeHeight(biome, bucket.X[index], bucket.Z[index], + out Color _); + + if (height >= waterLevel) + { + aboveWater++; + if (aboveWater > maxAboveWater) + { + return false; + } + } + } + + return true; + } + + /// + /// True when is under water everywhere, so a spawn point in it must + /// be allowed to be submerged. Falls back to the vanilla Ocean assumption when the index has + /// not been built on this client -- that is the pre-existing behaviour, never worse. + /// + internal static bool IsOpenWater(Heightmap.Biome biome) + { + if (_state != BiomeIndexState.Ready) + { + return biome == Heightmap.Biome.Ocean; + } + + return OpenWaterBiomes.Contains(biome); + } + + private static BiomeBucket BuildBucket(List cells) + { + int count = cells.Count; + var radii = new float[count]; + for (int i = 0; i < count; i++) + { + radii[i] = cells[i].magnitude; + } + + var order = new int[count]; + for (int i = 0; i < count; i++) + { + order[i] = i; + } + + var sortKeys = (float[])radii.Clone(); + Array.Sort(sortKeys, order); + + var bucket = new BiomeBucket + { + Radius = sortKeys, + X = new float[count], + Z = new float[count] + }; + + for (int i = 0; i < count; i++) + { + var cell = cells[order[i]]; + bucket.X[i] = cell.x; + bucket.Z[i] = cell.y; + } + + return bucket; + } + + internal static int CountCells(Heightmap.Biome biome) + { + return Buckets.TryGetValue(biome, out var bucket) ? bucket.Count : 0; + } + + internal static int CountCellsInBand(Heightmap.Biome biome, float minRadius, float maxRadius) + { + if (!Buckets.TryGetValue(biome, out var bucket)) + { + return 0; + } + + int lo = LowerBound(bucket.Radius, minRadius); + int hi = UpperBound(bucket.Radius, maxRadius); + return Mathf.Max(0, hi - lo); + } + + internal static IEnumerable IndexedBiomes => Buckets.Keys; + + private static string DescribeBuckets() + { + var sb = new StringBuilder(); + foreach (var pair in Buckets) + { + if (sb.Length > 0) + { + sb.Append(' '); + } + + sb.Append(BiomeDataManager.GetName(pair.Key)).Append('=').Append(pair.Value.Count); + if (OpenWaterBiomes.Contains(pair.Key)) + { + sb.Append("(water)"); + } + } + + return sb.Length == 0 ? "(no biomes indexed)" : sb.ToString(); + } + + internal static string Describe() + { + return $"state={_state} seed={_seed} cells={_cellCount} grid={_cellSize:0.#}m " + + $"build={_buildMs:0}ms/{_buildFrames}frames"; + } + + /// + /// Picks a world position in . Chooses a random anchor inside the + /// requested radius band, snaps to one of the nearest indexed cells of that biome, then + /// refines within that cell to an exact point. + /// + /// Validation is seed-only on purpose: nothing here loads a zone, so nothing here can see + /// terrain colliders, wards or player bases. AdventureSpawnController.DeterminespawnPoint + /// does that check with an expanding band search once the player is actually near the point, + /// and remains the authority on where a bounty or chest finally lands. + /// + internal static bool TryFindPoint(Heightmap.Biome biome, float minRadius, float maxRadius, + bool requireBand, out Vector3 point, out int candidatesTried) + { + point = Vector3.zero; + candidatesTried = 0; + + if (!Buckets.TryGetValue(biome, out var bucket) || bucket.Count == 0) + { + return false; + } + + int lo; + int hi; + if (requireBand) + { + lo = LowerBound(bucket.Radius, minRadius); + hi = UpperBound(bucket.Radius, maxRadius); + if (hi - lo <= 0) + { + return false; + } + } + else + { + lo = 0; + hi = bucket.Count; + } + + Vector2 anchor = SampleAnchor(minRadius, maxRadius); + int[] nearest = NearestScratch; + int nearestCount = FindNearestCells(bucket, lo, hi, anchor, nearest); + if (nearestCount == 0) + { + return false; + } + + OrderByPreference(bucket, biome, nearest, nearestCount); + + int cellsToTry = Mathf.Min(nearestCount, MaxCellsPerAttempt); + + // Two passes. The first insists the point's whole zone reads as this biome, which is what + // makes the placement search downstream agree with us; the second drops that so a biome + // too fragmented to offer a clean zone still yields something. + for (int pass = 0; pass < 2; pass++) + { + bool requireZoneAgreement = pass == 0; + + // Each pass gets its own budget, but the caller is told the running total -- a log + // line reporting only the last pass would understate what the search really cost. + int passCandidates = 0; + int alreadyTried = candidatesTried; + + // The nearest cells first, so the point stays near the anchor when it can. + for (int i = 0; i < cellsToTry; i++) + { + int index = nearest[i]; + var cell = new Vector2(bucket.X[index], bucket.Z[index]); + + bool found = TryRefineInCell(biome, cell, minRadius, maxRadius, requireBand, + requireZoneAgreement, ref passCandidates, out point); + candidatesTried = alreadyTried + passCandidates; + + if (found) + { + LastIssued[biome] = point; + return true; + } + + if (passCandidates >= MaxCandidatesPerAttempt) + { + break; + } + } + + // Every cell above sits in one neighbourhood around the anchor, so they can all be + // rejected for the same local reason -- an AshLands anchor that lands in a lava field + // or on the trench rejects its whole neighbourhood at once. These are strided across + // the biome's entire slice instead, starting from a random offset so repeat requests + // do not retry the same fallback cells. + int spreadCells = Mathf.Min(SpreadCellCount, hi - lo); + int spreadStart = UnityEngine.Random.Range(0, Mathf.Max(1, hi - lo)); + + for (int i = 0; i < spreadCells && passCandidates < MaxCandidatesPerAttempt; i++) + { + int index = lo + (spreadStart + (int)((long)i * (hi - lo) / spreadCells)) % (hi - lo); + var cell = new Vector2(bucket.X[index], bucket.Z[index]); + + bool found = TryRefineInCell(biome, cell, minRadius, maxRadius, requireBand, + requireZoneAgreement, ref passCandidates, out point); + candidatesTried = alreadyTried + passCandidates; + + if (found) + { + LastIssued[biome] = point; + return true; + } + } + } + + return false; + } + + /// + /// Area-uniform sample of the annulus, so points do not bunch against its inner edge. Same + /// formula AdventureSpawnController uses for its search bands. + /// + private static Vector2 SampleAnchor(float minRadius, float maxRadius) + { + float radius = Mathf.Sqrt(Mathf.Lerp(minRadius * minRadius, maxRadius * maxRadius, + UnityEngine.Random.value)); + float angle = UnityEngine.Random.Range(0f, Mathf.PI * 2f); + return new Vector2(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius); + } + + /// + /// Keeps the K cells closest to the anchor. K is tiny, so straight insertion into a fixed + /// buffer beats any heap, and the scan is over one biome's in-band slice rather than the + /// whole index. + /// + private static int FindNearestCells(BiomeBucket bucket, int lo, int hi, Vector2 anchor, + int[] nearest) + { + float[] bestDistance = NearestDistanceScratch; + int count = 0; + + for (int i = lo; i < hi; i++) + { + float dx = bucket.X[i] - anchor.x; + float dz = bucket.Z[i] - anchor.y; + float distance = dx * dx + dz * dz; + + if (count == NearestCellCount && distance >= bestDistance[count - 1]) + { + continue; + } + + int insertAt = count < NearestCellCount ? count : NearestCellCount - 1; + while (insertAt > 0 && bestDistance[insertAt - 1] > distance) + { + bestDistance[insertAt] = bestDistance[insertAt - 1]; + nearest[insertAt] = nearest[insertAt - 1]; + insertAt--; + } + + bestDistance[insertAt] = distance; + nearest[insertAt] = i; + + if (count < NearestCellCount) + { + count++; + } + } + + return count; + } + + /// + /// Shuffles the closest few cells and floats one clear of the last point issued for this + /// biome to the front. Without this, repeat requests keep resolving to the same nearest cell + /// and consecutive bounties land on top of each other. + /// + /// Only the front of the list is shuffled. The rest stays in distance order because it is the + /// fallback the search walks when the close cells fail, and shuffling all of it would turn + /// "the nearest patch of this biome" into "somewhere in a several-kilometre neighbourhood". + /// + private static void OrderByPreference(BiomeBucket bucket, Heightmap.Biome biome, + int[] nearest, int count) + { + int shuffled = Mathf.Min(count, VarietyShuffleCount); + for (int i = shuffled - 1; i > 0; i--) + { + int j = UnityEngine.Random.Range(0, i + 1); + (nearest[i], nearest[j]) = (nearest[j], nearest[i]); + } + + if (!LastIssued.TryGetValue(biome, out var last)) + { + return; + } + + // Confined to the shuffled front for the same reason: promoting a cell from deep in the + // distance-ordered tail would trade "not the same clearing twice" for "kilometres away". + float spacingSq = PreferredSpacing * PreferredSpacing; + for (int i = 0; i < shuffled; i++) + { + float dx = bucket.X[nearest[i]] - last.x; + float dz = bucket.Z[nearest[i]] - last.z; + if (dx * dx + dz * dz >= spacingSq) + { + (nearest[0], nearest[i]) = (nearest[i], nearest[0]); + return; + } + } + } + + /// + /// Turns a grid node into a real position. + /// + /// The cell centre is tried first and is the only candidate guaranteed to still be in the + /// biome -- it is the exact point the index sampled. If it fails, this gives up on the cell + /// immediately rather than spending the budget on jittered points, because in a fragmented + /// biome those are mostly outside it and every one of them is budget not spent on a cell that + /// has never been looked at. Jitter is for variety, so it is only paid for after a centre has + /// already proven the spot is usable, and it falls back to that centre if none of it lands. + /// + private static bool TryRefineInCell(Heightmap.Biome biome, Vector2 cell, + float minRadius, float maxRadius, bool requireBand, bool requireZoneAgreement, + ref int candidatesTried, out Vector3 point) + { + if (!TryCandidate(biome, cell, minRadius, maxRadius, requireBand, requireZoneAgreement, + ref candidatesTried, out point)) + { + return false; + } + + // The centre works, so a point here exists. Everything below only looks for a nicer one: + // returning centres verbatim would snap every bounty in the world to the sample grid. + float jitterRadius = _cellSize * 0.5f; + + for (int i = 0; i < VarietyAttempts && candidatesTried < MaxCandidatesPerAttempt; i++) + { + Vector2 candidate = cell + UnityEngine.Random.insideUnitCircle * jitterRadius; + if (TryCandidate(biome, candidate, minRadius, maxRadius, requireBand, requireZoneAgreement, + ref candidatesTried, out Vector3 jittered)) + { + point = jittered; + return true; + } + } + + return true; + } + + /// + /// The predicate chain, cheapest test first. Only candidates that already match the biome pay + /// for a height sample -- the AshLands height is cellular noise and roughly 20-30x the cost + /// of a Meadows one. + /// + private static bool TryCandidate(Heightmap.Biome biome, Vector2 candidate, + float minRadius, float maxRadius, bool requireBand, bool requireZoneAgreement, + ref int candidatesTried, out Vector3 point) + { + point = Vector3.zero; + candidatesTried++; + + float radius = candidate.magnitude; + + // Stay clear of the world edge, where height collapses to -400 regardless of biome. + if (radius > WorldExtent.TotalRadius - _cellSize) + { + return false; + } + + if (requireBand && (radius < minRadius || radius > maxRadius)) + { + return false; + } + + var worldGenerator = WorldGenerator.instance; + if (worldGenerator == null) + { + return false; + } + + if (worldGenerator.GetBiome(candidate.x, candidate.y) != biome) + { + return false; + } + + float height = worldGenerator.GetBiomeHeight(biome, candidate.x, candidate.y, out Color mask); + + // An open-water biome is submerged by definition, so every point in it would fail this + // test; everywhere else, water means a lake or a river and the point is no good. Which + // biomes those are is measured per world rather than assumed, so a custom ocean-like + // biome works without being named here -- see IsBucketOpenWater. + // This is also what rejects the trench that CreateAshlandsGap/CreateDeepNorthGap carve + // around the polar caps, where a biome match sits under tens of metres of water. + if (!IsOpenWater(biome) && height < ZoneSystem.instance.m_waterLevel - 2f) + { + return false; + } + + // mask.a is the Ashlands lava mask, the same value ZoneSystem.IsLavaPreHeightmap tests -- + // already in hand here, where that method would re-derive the biome and the height. + // + // The AshLands gate is required, not an oversight, and must not be generalised to custom + // biomes: mask.a is a shared channel whose meaning is per biome, and GetMistlandsHeight + // writes it too. Testing it unconditionally would reject Mistlands points that have no + // lava anywhere near them. Vanilla draws the same line -- Heightmap.IsLava and + // ZoneSystem.IsLavaPreHeightmap both check for AshLands before reading the mask -- so + // lava is an AshLands-only concept to the engine as well, not just to us. + if (biome == Heightmap.Biome.AshLands && mask.a > 0.6f) + { + return false; + } + + if (requireZoneAgreement && !ZoneAgreesOnBiome(worldGenerator, candidate, biome)) + { + return false; + } + + // Y only has to put the spawner's downward ground raycast (which starts 5000m above the + // given point) over real terrain. Height plus the long-standing 100m offset does that + // everywhere, and unlike the old picker it is an actual terrain height rather than a + // constant. + point = new Vector3(candidate.x, height + 100f, candidate.y); + return true; + } + + /// + /// True when every corner of the point's zone reads as . + /// + /// This is what makes the seed-level pick agree with the loaded world. A built Heightmap does + /// not evaluate the biome per point: HeightmapBuilder samples GetBiome at the four corners of + /// the 64m zone, and Heightmap.GetBiome returns the nearest-corner-weighted winner among + /// those four -- short-circuiting to one answer for the whole zone when they agree. So a + /// point can sit well inside a swamp and still be reported as meadows by the GetGroundData + /// check AdventureSpawnController runs before it places anything, which would send the + /// placement search off expanding bands for no reason. Four extra GetBiome calls make it a + /// guarantee instead. + /// + private static bool ZoneAgreesOnBiome(WorldGenerator worldGenerator, Vector2 point, + Heightmap.Biome biome) + { + Vector3 zonePos = ZoneSystem.GetZonePos(ZoneSystem.GetZone(new Vector3(point.x, 0f, point.y))); + const float half = ZoneSystem.c_ZoneHalfSize; + + return worldGenerator.GetBiome(zonePos.x - half, zonePos.z - half) == biome && + worldGenerator.GetBiome(zonePos.x + half, zonePos.z - half) == biome && + worldGenerator.GetBiome(zonePos.x - half, zonePos.z + half) == biome && + worldGenerator.GetBiome(zonePos.x + half, zonePos.z + half) == biome; + } + + /// + /// Counts why cell centres in a biome are rejected. Purely diagnostic -- a search that fails + /// can only report "no usable location", which does not say whether the biome is drowned, + /// paved in lava, or simply outside its configured band. Uses cell centres only, so every + /// sample is known to be in the biome and the tally is about the terrain, not the sampling. + /// + internal static string DescribeRejections(Heightmap.Biome biome, float minRadius, float maxRadius, + int sampleLimit = 512) + { + if (!Buckets.TryGetValue(biome, out var bucket) || bucket.Count == 0) + { + return "no cells indexed"; + } + + var worldGenerator = WorldGenerator.instance; + if (worldGenerator == null) + { + return "world generator unavailable"; + } + + float waterLevel = ZoneSystem.instance.m_waterLevel; + bool openWater = IsOpenWater(biome); + int samples = Mathf.Min(sampleLimit, bucket.Count); + int outsideWorld = 0, outsideBand = 0, submerged = 0, lava = 0, zoneEdge = 0, usable = 0; + + for (int i = 0; i < samples; i++) + { + int index = (int)((long)i * bucket.Count / samples); + var cell = new Vector2(bucket.X[index], bucket.Z[index]); + float radius = cell.magnitude; + + if (radius > WorldExtent.TotalRadius - _cellSize) + { + outsideWorld++; + continue; + } + + if (radius < minRadius || radius > maxRadius) + { + outsideBand++; + continue; + } + + float height = worldGenerator.GetBiomeHeight(biome, cell.x, cell.y, out Color mask); + + if (!openWater && height < waterLevel - 2f) + { + submerged++; + continue; + } + + if (biome == Heightmap.Biome.AshLands && mask.a > 0.6f) + { + lava++; + continue; + } + + if (!ZoneAgreesOnBiome(worldGenerator, cell, biome)) + { + zoneEdge++; + continue; + } + + usable++; + } + + return $"of {samples} sampled cells: {usable} usable, {outsideBand} outside band, " + + $"{submerged} submerged, {lava} lava, {zoneEdge} on a mixed-biome zone, " + + $"{outsideWorld} past the world edge"; + } + + private static int LowerBound(float[] sorted, float value) + { + int lo = 0; + int hi = sorted.Length; + while (lo < hi) + { + int mid = lo + (hi - lo) / 2; + if (sorted[mid] < value) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + + private static int UpperBound(float[] sorted, float value) + { + int lo = 0; + int hi = sorted.Length; + while (lo < hi) + { + int mid = lo + (hi - lo) / 2; + if (sorted[mid] <= value) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + + return lo; + } + } +} diff --git a/EpicLoot/src/Adventure/WorldExtent.cs b/EpicLoot/src/Adventure/WorldExtent.cs new file mode 100644 index 00000000..e349a6d5 --- /dev/null +++ b/EpicLoot/src/Adventure/WorldExtent.cs @@ -0,0 +1,287 @@ +using System; +using System.Reflection; +using BepInEx.Bootstrap; +using HarmonyLib; +using UnityEngine; + +namespace EpicLoot.Adventure +{ + /// + /// The one place that answers "how big is this world". + /// + /// Nothing in the adventure code may read WorldGenerator.worldSize or + /// WorldGenerator.waterEdge. Both are public const, so the C# compiler inlines + /// 10000/10500 into EpicLoot.dll at build time -- no field exists at runtime for a world-size mod + /// to patch, and vanilla itself never reads either one (every real check uses an inline literal, + /// which is exactly why Expand World Size has to transpile them). Reading one silently reports a + /// 10km world on a 40km map. + /// + internal static class WorldExtent + { + /// The radius vanilla ships. Everything here is expressed relative to it. + internal const float VanillaPlayableRadius = 10000f; + internal const float VanillaTotalRadius = 10500f; + + /// + /// Expand World Size's default edge band. Only used by the probe fallback, which can measure + /// the total radius exactly but has no way to see where the edge band starts. + /// + private const float AssumedEdgeSize = 500f; + + /// Terrain generates out to here. Vanilla 10000. + internal static float PlayableRadius { get; private set; } = VanillaPlayableRadius; + + /// Past here height is hard-clamped to -400. Vanilla 10500. + internal static float TotalRadius { get; private set; } = VanillaTotalRadius; + + /// + /// Biome feature-size multiplier. A bigger radius alone means *more* biome patches of the + /// same size; only stretch makes the patches themselves bigger. Vanilla 1. + /// + internal static float Stretch { get; private set; } = 1f; + + /// Where the numbers came from, for the console command and the log line. + internal static string Source { get; private set; } = "vanilla"; + + /// + /// Factor for rescaling config radii that were authored in metres against a vanilla world. + /// + internal static float RadiusScale => PlayableRadius / VanillaPlayableRadius; + + /// + /// A probe result this close to the vanilla radius is reported as vanilla, so an unmodded + /// install gets an exact scale of 1 and a clean log line instead of 10499.98m. + /// + private const float VanillaSnapTolerance = 50f; + + /// + /// The probe walks real terrain heights, so it is not free. Expand World Size -- the case + /// that actually resizes a world mid-session -- is read through the cheap reflection path + /// above, so the probe only needs to run often enough to notice some other mod doing it. + /// + private const float ProbeIntervalSeconds = 30f; + + private static bool _probed; + private static float _probedAt; + private static bool _probeSucceeded; + private static float _probedTotalRadius; + + private static bool _resolvedEws; + private static MethodInfo _ewsWorldRadius; + private static MethodInfo _ewsTotalRadius; + private static MethodInfo _ewsStretch; + + /// + /// Re-resolves the world extent. Cheap enough to call on every merchant open and every spawn + /// point request, which is what catches a mid-session resize: Expand World Size can be + /// re-synchronized from the server or hot-reloaded from disk at any time, and the world seed + /// does not change when it happens. + /// + internal static void Refresh() + { + float playable = VanillaPlayableRadius; + float total = VanillaTotalRadius; + float stretch = 1f; + string source = "vanilla"; + + if (TryReadExpandWorldSize(ref playable, ref total, ref stretch)) + { + source = "expand_world_size"; + } + else if (TryProbeWorldEdgeThrottled(out float probedTotal)) + { + if (Mathf.Abs(probedTotal - VanillaTotalRadius) <= VanillaSnapTolerance) + { + // An unmodded world. Use the exact constants rather than the probe's + // within-a-metre answer, so RadiusScale is exactly 1 and nothing downstream + // rescales a config band by 0.99999. + total = VanillaTotalRadius; + playable = VanillaPlayableRadius; + } + else + { + total = probedTotal; + playable = Mathf.Max(1f, probedTotal - AssumedEdgeSize); + source = "probe"; + } + } + + // A hand-broken config or a mod mid-reload can hand back nonsense; refusing it keeps the + // previous good values rather than poisoning every downstream radius. + if (!(playable > 0f) || !(total >= playable) || !(stretch > 0f)) + { + EpicLoot.LogWarningForce($"Ignoring implausible world extent from {source}: " + + $"playable={playable}, total={total}, stretch={stretch}. " + + $"Keeping playable={PlayableRadius}, total={TotalRadius}, stretch={Stretch}."); + return; + } + + if (Mathf.Approximately(playable, PlayableRadius) && + Mathf.Approximately(total, TotalRadius) && + Mathf.Approximately(stretch, Stretch) && + source == Source) + { + return; + } + + PlayableRadius = playable; + TotalRadius = total; + Stretch = stretch; + Source = source; + + EpicLoot.LogForce($"Adventure world extent: {Describe()}"); + } + + /// + /// Forgets the cached probe. Called on world change, where the throttle would otherwise keep + /// reporting the previous world's size for up to . + /// + internal static void InvalidateProbe() + { + _probed = false; + _probeSucceeded = false; + _probedTotalRadius = VanillaTotalRadius; + } + + internal static string Describe() + { + return $"playable={PlayableRadius:0.#}m total={TotalRadius:0.#}m " + + $"stretch={Stretch:0.###} scale={RadiusScale:0.###} (source: {Source})"; + } + + /// + /// Reads Expand World Size's own configuration through its plugin instance. Soft: no assembly + /// reference, no BepInDependency, and any failure falls through to the probe. EWS is + /// authoritative when stacked with Expand World Data or Better Continents -- it pushes its + /// size into both of them -- so it is the right thing to ask when present. + /// + private static bool TryReadExpandWorldSize(ref float playable, ref float total, ref float stretch) + { + try + { + if (!_resolvedEws) + { + _resolvedEws = true; + + if (Chainloader.PluginInfos.TryGetValue("expand_world_size", out var info) && + info?.Instance != null) + { + var configType = info.Instance.GetType().Assembly + .GetType("ExpandWorldSize.Configuration"); + if (configType != null) + { + _ewsWorldRadius = AccessTools.PropertyGetter(configType, "WorldRadius"); + _ewsTotalRadius = AccessTools.PropertyGetter(configType, "WorldTotalRadius"); + _ewsStretch = AccessTools.PropertyGetter(configType, "WorldStretch"); + } + } + } + + if (_ewsWorldRadius == null || _ewsTotalRadius == null || _ewsStretch == null) + { + return false; + } + + playable = (float)_ewsWorldRadius.Invoke(null, null); + total = (float)_ewsTotalRadius.Invoke(null, null); + stretch = (float)_ewsStretch.Invoke(null, null); + return true; + } + catch (Exception e) + { + // Their internals moved, or the config is mid-reload. Degrade to the probe rather + // than taking the adventure system down with it. + EpicLoot.LogWarning($"Could not read Expand World Size configuration ({e.Message}); " + + "falling back to probing the world edge."); + _ewsWorldRadius = null; + _ewsTotalRadius = null; + _ewsStretch = null; + return false; + } + } + + /// + /// , rate limited, and re-probed from scratch whenever the + /// world changes. + /// + private static bool TryProbeWorldEdgeThrottled(out float totalRadius) + { + if (_probed && Time.unscaledTime - _probedAt < ProbeIntervalSeconds) + { + totalRadius = _probedTotalRadius; + return _probeSucceeded; + } + + _probed = true; + _probedAt = Time.unscaledTime; + _probeSucceeded = TryProbeWorldEdge(out _probedTotalRadius); + totalRadius = _probedTotalRadius; + return _probeSucceeded; + } + + /// + /// Measures the world edge directly, so it works against any world-size mod rather than only + /// the one we know by name. GetBiomeHeight hard-returns -2 * GetHeightMultiplier() (-400) + /// past the total radius, and nothing inside the world comes close -- the deepest ocean is + /// about -26 -- so that value is an exact sentinel. Binary search costs ~40 GetHeight calls. + /// + private static bool TryProbeWorldEdge(out float totalRadius) + { + totalRadius = VanillaTotalRadius; + + var worldGenerator = WorldGenerator.instance; + if (worldGenerator == null) + { + return false; + } + + float outsideHeight = -2f * WorldGenerator.GetHeightMultiplier(); + const float searchCeiling = 1000000f; + + // Establish a bracket: inside must be in-world, outside must be past the edge. + float inside = 0f; + float outside = VanillaTotalRadius; + while (!IsOutside(worldGenerator, outside, outsideHeight)) + { + inside = outside; + outside *= 2f; + if (outside > searchCeiling) + { + // No edge anywhere sane. Something else is patching heights; leave the caller on + // its previous values rather than inventing a world size. + return false; + } + } + + // A world smaller than vanilla is legal too, so do not assume the vanilla radius is inside. + if (IsOutside(worldGenerator, inside, outsideHeight)) + { + inside = 0f; + } + + for (int i = 0; i < 40 && outside - inside > 1f; i++) + { + float mid = (inside + outside) * 0.5f; + if (IsOutside(worldGenerator, mid, outsideHeight)) + { + outside = mid; + } + else + { + inside = mid; + } + } + + totalRadius = inside; + return totalRadius > 0f; + } + + private static bool IsOutside(WorldGenerator worldGenerator, float radius, float outsideHeight) + { + // Sampled on two axes: the edge test is a pure radius comparison today, but two samples + // cost nothing at 40 iterations and a directional edge would otherwise read as noise. + return Mathf.Approximately(worldGenerator.GetHeight(radius, 0f), outsideHeight) && + Mathf.Approximately(worldGenerator.GetHeight(0f, radius), outsideHeight); + } + } +} diff --git a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs index 68f35d2a..9d19029c 100644 --- a/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs +++ b/EpicLoot/src/Adventure/bounties/AdventureSpawnController.cs @@ -177,9 +177,11 @@ private void SpawnBountyTargets(BountyInfo bounty) } } - // An ocean bounty's targets swim, so they hold the water line the search settled on - // instead of being dropped onto a seabed that is tens of metres further down. - bool swimmingTargets = bounty.Biome == Heightmap.Biome.Ocean; + // An open-water bounty's targets swim, so they hold the water line the search settled on + // instead of being dropped onto a seabed that is tens of metres further down. Which + // biomes count as open water is measured from the world, so a custom ocean-like biome + // gets swimmers too rather than a pile of drowned creatures on the seabed. + bool swimmingTargets = WorldBiomeIndex.IsOpenWater(bounty.Biome); float baseHeight = point.y; for (var index = 0; index < prefabs.Count; index++) @@ -248,6 +250,11 @@ private void SpawnChest(TreasureMapChestInfo treasure) internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, Heightmap.Biome biome, WaterPlacement waterPlacement = WaterPlacement.Reject) { + // The owner of this spawner is not necessarily the player who bought it, so the biome + // index may never have been built on this client. Start it now; the waits below give it + // far longer than it needs, and IsOpenWater falls back safely if it is somehow not ready. + WorldBiomeIndex.EnsureBuilt(); + yield return new WaitForSeconds(5); while (!ZNetScene.instance.IsAreaReady(startingSpawnPoint)) @@ -259,10 +266,12 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, float radius = AdventureDataManager.Config.TreasureMap.MinimapAreaRadius; float waterSurface = ZoneSystem.instance.m_waterLevel; - // The Ocean biome is open water by definition -- its biome cutoff sits roughly 25m below - // the water line, so every point inside it is under water. Rejecting submerged points - // there rejected every candidate in every band, which is why no ocean bounty ever placed. - bool spawnInOpenWater = biome == Heightmap.Biome.Ocean && + // An open-water biome sits below the water line everywhere -- Ocean's biome cutoff is + // roughly 25m under it. Rejecting submerged points there rejected every candidate in + // every band, which is why no ocean bounty ever placed. Asking the biome index rather + // than testing for Ocean by name extends that fix to any ocean-like biome another mod + // adds, which would otherwise hit exactly the same dead end. + bool spawnInOpenWater = WorldBiomeIndex.IsOpenWater(biome) && waterPlacement != WaterPlacement.Reject; int maxExpansions = Mathf.Max(0, AdventureDataManager.Config.TreasureMap.MaxSpawnSearchExpansions); Vector3 determinedSpawn = startingSpawnPoint; @@ -339,7 +348,11 @@ internal IEnumerator DeterminespawnPoint(Vector3 startingSpawnPoint, continue; } - // Prevent spawning in Lava unless a last resort + // Prevent spawning in Lava unless a last resort. The AshLands gate stays: the + // vegetation mask is a shared channel with a different meaning per biome, and + // vanilla's own Heightmap.IsLava checks for AshLands before reading it, so lava + // is an AshLands-only concept to the engine rather than a trait a custom biome + // could carry. if (biome == Heightmap.Biome.AshLands && hmap.GetVegetationMask(determinedSpawn) > 0.45f) { diff --git a/EpicLoot/src/Adventure/feature/Bosses.cs b/EpicLoot/src/Adventure/feature/Bosses.cs index ec382031..7a37a769 100644 --- a/EpicLoot/src/Adventure/feature/Bosses.cs +++ b/EpicLoot/src/Adventure/feature/Bosses.cs @@ -1,21 +1,16 @@ -namespace EpicLoot.Adventure.Feature +using EpicLoot.Biomes; + +namespace EpicLoot.Adventure.Feature { public static class Bosses { + /// + /// The boss key one step earlier in biome progression order (biomedata.json), or null for the + /// first key. Never throws: an empty registry just means there is no previous key. + /// public static string GetPrevBossKey(string bossKey) { - if (AdventureDataManager.Config.Bounties.Bosses == null || AdventureDataManager.Config.Bounties.Bosses.Count == 0) - { - throw new System.Exception(($"[Adventure Bounty Error]: adventure.json has not been updated or is missing boss config. " + - $"Please update JSON files.")); - } - - var index = AdventureDataManager.Config.Bounties.Bosses.FindIndex(x => x.BossDefeatedKey == bossKey); - - if (index <= 0 || index >= AdventureDataManager.Config.Bounties.Bosses.Count) - return null; - - return AdventureDataManager.Config.Bounties.Bosses[index - 1].BossDefeatedKey; + return BiomeDataManager.GetPrevBossKey(bossKey); } } } diff --git a/EpicLoot/src/Adventure/feature/Bounties.cs b/EpicLoot/src/Adventure/feature/Bounties.cs index 0c331106..7447e1c1 100755 --- a/EpicLoot/src/Adventure/feature/Bounties.cs +++ b/EpicLoot/src/Adventure/feature/Bounties.cs @@ -1,5 +1,6 @@ using BepInEx; using Common; +using EpicLoot.Biomes; using EpicLoot.Config; using EpicLoot_UnityLib; using HarmonyLib; @@ -48,65 +49,37 @@ public List GetAvailableBounties(int interval, bool removeAcceptedBo var random = GetRandomForInterval(interval, RefreshInterval); var bountiesPerBiome = new MultiValueDictionary(); - - var defeatedBossBiomes = new List(); - var previousBossKilled = false; - var previousBoss = ""; bool bossBountiesGated = BossBountiesGated(); + HashSet unlockedBiomes = bossBountiesGated ? + ComputeUnlockedBiomes(ELConfig.BossBountyMode.Value, key => ZoneSystem.instance.GetGlobalKey(key)) : + null; - if (bossBountiesGated) + foreach (var targetConfig in AdventureDataManager.Config.Bounties.Targets) { - foreach (var bossConfig in AdventureDataManager.Config.Bounties.Bosses) + // Only targets that exist in this game count, regardless of what the config says + if (PrefabManager.Instance.GetPrefab(targetConfig.TargetID) == null) { - if (previousBoss == "" && ELConfig.BossBountyMode.Value == GatedBountyMode.BossKillUnlocksNextBiomeBounties) - { - defeatedBossBiomes.Add(bossConfig.Biome); - previousBoss = bossConfig.BossDefeatedKey; - } - - if (ZoneSystem.instance.GetGlobalKey(bossConfig.BossDefeatedKey)) - { - defeatedBossBiomes.Add(bossConfig.Biome); - previousBossKilled = true; - previousBoss = bossConfig.BossDefeatedKey; - } - else if ((previousBossKilled || previousBoss.Equals(bossConfig.BossDefeatedKey)) && - ELConfig.BossBountyMode.Value == GatedBountyMode.BossKillUnlocksNextBiomeBounties) - { - defeatedBossBiomes.Add(bossConfig.Biome); - previousBoss = bossConfig.BossDefeatedKey; - previousBossKilled = false; - } + EpicLoot.Log($"Could not find bounty prefab {targetConfig.TargetID}"); + continue; } - } - // When we build the list of potential targets we only want to include those that are in the game, regardless of what the config says - List targetable_bounty_configs = new List(); - foreach (var potential_bounty in AdventureDataManager.Config.Bounties.Targets) - { - if (PrefabManager.Instance.GetPrefab(potential_bounty.TargetID) == null) + // A biome name the registry could not resolve was reported when the config loaded; + // the entry simply cannot be offered. + Heightmap.Biome biome = targetConfig.GetBiome(); + if (biome == Heightmap.Biome.None) { - EpicLoot.Log($"Could not find bounty prefab {potential_bounty.TargetID}"); continue; } - else - { - targetable_bounty_configs.Add(potential_bounty); - } - } - foreach (var targetConfig in targetable_bounty_configs) - { - if ((bossBountiesGated && !defeatedBossBiomes.Contains(targetConfig.Biome)) || - !player.m_knownBiome.Contains(targetConfig.Biome)) + if ((bossBountiesGated && !unlockedBiomes.Contains(biome)) || !player.m_knownBiome.Contains(biome)) { // Remove the results of undefeated biome bosses & // Remove the results that the player doesn't know about yet continue; } - bountiesPerBiome.Add(targetConfig.Biome, targetConfig); + bountiesPerBiome.Add(biome, targetConfig); } var selectedTargets = new List(); @@ -120,7 +93,7 @@ public List GetAvailableBounties(int interval, bool removeAcceptedBo var results = selectedTargets.Select(targetConfig => new BountyInfo() { - Biome = targetConfig.Biome, + Biome = targetConfig.GetBiome(), Interval = interval, PlayerID = player.GetPlayerID(), Target = new BountyTargetInfo() { @@ -145,6 +118,46 @@ public List GetAvailableBounties(int interval, bool removeAcceptedBo return results; } + /// + /// The biomes allowed to offer bounties under a gated mode. A biome is defeated when every one + /// of its boss keys is set (no keys: always). Under BossKillUnlocksNextBiomeBounties a biome is + /// also unlocked when the biome group before it is defeated, where consecutive biomes sharing + /// one key set form a group: Ocean rides on BlackForest's key, so Eikthyr alone unlocks both, + /// exactly as the old Bosses-list walk did. + /// + internal static HashSet ComputeUnlockedBiomes(GatedBountyMode mode, Func hasKey) + { + var unlocked = new HashSet(); + bool nextMode = mode == GatedBountyMode.BossKillUnlocksNextBiomeBounties; + bool previousGroupDefeated = true; // nothing precedes the first biome + bool currentGroupDefeated = false; + IReadOnlyList currentGroupKeys = null; + + foreach (BiomeDefinition definition in BiomeDataManager.BiomesInOrder) + { + bool defeated = definition.BossDefeatedKeys.All(hasKey); + if (currentGroupKeys != null && !SameKeySet(currentGroupKeys, definition.BossDefeatedKeys)) + { + previousGroupDefeated = currentGroupDefeated; + } + + currentGroupKeys = definition.BossDefeatedKeys; + currentGroupDefeated = defeated; + + if (defeated || (nextMode && previousGroupDefeated)) + { + unlocked.Add(definition.Biome); + } + } + + return unlocked; + } + + private static bool SameKeySet(IReadOnlyList a, IReadOnlyList b) + { + return a.Count == b.Count && a.All(b.Contains); + } + public static string PrintBounties(string label, List results) { var sb = new StringBuilder(); @@ -219,6 +232,10 @@ public IEnumerator AcceptBounty(Player player, BountyInfo bounty, Action { private readonly MerchantPanel _merchantPanel; - // Flag to prevent spam accepting multiple bounties. Instance state with a time failsafe: - // it used to be a static cleared only inside the accept coroutine's callback -- if the - // hosting Player died mid-search the coroutine died with it, and the latched static left - // the Accept button silently dead for the rest of the process. - private bool _generatingBounty = false; - private float _generatingBountyStarted; - private const float GeneratingBountyTimeout = 30f; - public AvailableBountiesListPanel(MerchantPanel merchantPanel, BountyListElement elementPrefab) : base( merchantPanel.transform.Find("Bounties/AvailableBountiesPanel/ItemList") as RectTransform, @@ -60,32 +52,40 @@ protected override void OnMainButtonClicked() } var bounty = GetSelectedItem(); - bool latchExpired = _generatingBounty && - Time.unscaledTime - _generatingBountyStarted > GeneratingBountyTimeout; - if ((!_generatingBounty || latchExpired) && bounty != null && bounty.BountyInfo.State == BountyState.Available) + if (bounty == null || bounty.BountyInfo.State != BountyState.Available || !TryBeginAction()) + { + return; + } + + EpicLoot.Log("Trying to accept bounty..."); + + // Hosted on the adventure driver rather than the Player: a coroutine on the player dies + // with that object on death, logout or world change, and its completion callback -- the + // one that clears the latch and refreshes the list -- would never run. + AdventureCacheDriver.Run(AdventureDataManager.Bounties.AcceptBounty( + player, bounty.BountyInfo, (success, position) => { - _generatingBounty = true; - _generatingBountyStarted = Time.unscaledTime; - EpicLoot.Log("Trying to accept bounty..."); - player.StartCoroutine(AdventureDataManager.Bounties.AcceptBounty( - player, bounty.BountyInfo, (success, position) => + if (success && StoreGui.instance != null && _merchantPanel != null) { - if (success && StoreGui.instance != null && _merchantPanel != null) - { - RefreshItems(_merchantPanel.GetPlayerCurrencies()); + RefreshItems(_merchantPanel.GetPlayerCurrencies()); - if (StoreGui.instance.m_trader != null) - { - StoreGui.instance.m_trader.OnBought(new Trader.TradeItem { m_price = 0 }); - } + if (StoreGui.instance.m_trader != null) + { + StoreGui.instance.m_trader.OnBought(new Trader.TradeItem { m_price = 0 }); + } - StoreGui.instance.m_buyEffects?.Create(player.transform.position, Quaternion.identity); + // The player can be gone by the time this lands, since the coroutine now + // outlives them. + var localPlayer = Player.m_localPlayer; + if (localPlayer != null) + { + StoreGui.instance.m_buyEffects?.Create(localPlayer.transform.position, Quaternion.identity); } + } - _generatingBounty = false; - EpicLoot.Log($"Done trying to accept bounty. Success: {success}"); - })); - } + EndAction(); + EpicLoot.Log($"Done trying to accept bounty. Success: {success}"); + })); } public override void RefreshItems(Currencies currencies) diff --git a/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs b/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs index 1b07dc39..426235f0 100755 --- a/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs +++ b/EpicLoot/src/Adventure/feature/BountyLocationEarlyCache.cs @@ -1,491 +1,190 @@ using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; +using EpicLoot.Biomes; using UnityEngine; namespace EpicLoot.Adventure.Feature { + /// + /// Picks the world position a bounty target or treasure chest is sent to. + /// + /// The name is historical: this used to keep a cache of pre-vetted points because finding one was + /// slow enough to need hiding. It no longer is. A request now samples + /// -- a seed-derived map of where each biome is -- picks an anchor inside the biome's configured + /// radius band, and refines to an exact point, all from WorldGenerator with nothing instantiated. + /// That takes tens of microseconds, so there is nothing left worth caching, and the cache's own + /// failure modes (a biome running dry, a stuck refill latch blocking every later request) are gone + /// with it. + /// + /// Everything here is seed-only and therefore blind to terrain colliders, wards and player bases. + /// checks all of those with an + /// expanding band search once the player is actually near the point, and stays the authority on + /// where the bounty or chest finally lands. + /// internal static class BountyLocationEarlyCache { - // Per-client and deliberately unsynchronized: two players picking different spots for their own - // bounties is fine, and the alternative (a shared list) would need an anchor to live on -- - // Haldor has none, since vanilla Trader carries no ZNetView and no networked state at all. - // - // Lifetime is the client's session, not the merchant panel's. Reset() clears it on world change - // (points are world-specific); nothing else empties it, so reopening the merchant is free. - public static Dictionary> PotentialBiomeLocations = - new Dictionary> { }; - - // Target and low-water mark are separate. The old single value of 3 was both the goal and the - // cap, and SelectSpawnPoint consumes one per bounty or treasure map, so a biome ran dry after - // two of them -- and every bounty after that paid for a full blocking search. - private const int TargetPointsPerBiome = 12; - private const int RefillThreshold = 4; - - private const int MaxTriesPerBiomeFill = 60; - private const int MaxTriesOnDemand = 100; - - // A candidate whose zone will not come up is not worth waiting on forever; move to the next one. - private const int MaxZoneWaitFrames = 120; - - // Only one top-up coroutine at a time, so repeated merchant visits cannot stack them. - private static bool _refilling; - - private static Dictionary> GetRadiusRanges() - { - var adventureSave = Player.m_localPlayer.GetAdventureSaveData(); - Dictionary> radiusRanges = new(); - Heightmap.Biome[] biomeList = AdventureDataManager.Config.TreasureMap.GetBiomeList(); - - for (int i = 0; i < biomeList.Length; i++) - { - if (!radiusRanges.ContainsKey(biomeList[i])) - { - var biomeConfig = GetBiomeInfoConfig(biomeList[i]); - radiusRanges.Add(biomeList[i], - new Tuple(biomeConfig.MinRadius, biomeConfig.MaxRadius)); - } - } - - return radiusRanges; - } + /// + /// How long a request will wait for a world biome index that is still building. Generous + /// because it has to cover a first build on a very large world; the realistic wait is a + /// fraction of a second, and the merchant panel normally warms the index on open anyway. + /// + private const float IndexWaitTimeoutSeconds = 30f; /// - /// Drops the cache. Called on world change -- every point is a world position, so carrying them - /// into another world would hand out spawn points from the wrong map. + /// Drops all world-derived state. Called on world change -- every point is a world position, + /// so carrying anything over would hand out locations from the wrong map. /// public static void Reset() { - // Stop first: a top-up still in flight would otherwise finish against the new world and file - // points it picked from the old one. + // Stop first: a build still in flight would otherwise finish against the new world and + // publish an index sampled from the old one. AdventureCacheDriver.StopAll(); - PotentialBiomeLocations.Clear(); - _refilling = false; - } - - internal static int CachedPointCount(Heightmap.Biome biome) - { - return PotentialBiomeLocations.TryGetValue(biome, out var points) ? points.Count : 0; - } - - internal static void TryAddBiomePoint(Heightmap.Biome biome, Vector3 point) - { - if (!PotentialBiomeLocations.TryGetValue(biome, out var points)) - { - points = new List(); - PotentialBiomeLocations.Add(biome, points); - } - - if (points.Count >= TargetPointsPerBiome) - { - return; - } - - points.Add(point); - } - - public static IEnumerator TryGetBiomePoint( - Heightmap.Biome biome, AdventureSaveData saveData, Action onComplete) - { - // Anything cached is served immediately -- the player is standing at the merchant waiting on - // a bounty they have already paid for, so searching here is the one thing worth avoiding. - if (CachedPointCount(biome) > 0) - { - SelectSpawnPoint(biome, onComplete); - RequestRefill(); - yield break; - } - - // Nothing banked for this biome, so there is no choice but to search now. - yield return AddBiomePointLazyCache(GetRadiusRanges(), biome, true, onComplete); - RequestRefill(); + WorldBiomeIndex.Reset(); } /// - /// Tops the cache up to . Called from MerchantPanel.Awake, so - /// it runs again every time the panel is rebuilt -- it deliberately does not clear anything, and - /// returns immediately when every biome is already stocked. Regenerating on each merchant visit - /// is what made accepting a bounty pay for hundreds of world-point tests. + /// Finds a spawn point in and hands it to + /// . + /// + /// is unused. It is kept because both callers pass it and because + /// the radius ramp that once read it (widening the search band by how many bounties a player + /// had taken) may come back; see the obsolete StartRadius/RadiusInterval config fields. + /// + /// is invoked on every exit path, success or failure. That is + /// the contract that matters here: the old implementation could give up silently, and the + /// symptom players saw was a merchant button that did nothing at all. /// - public static IEnumerator PopulateCacheFromStart() + public static IEnumerator TryGetBiomePoint( + Heightmap.Biome biome, AdventureSaveData saveData, Action onComplete) { - // Can't setup the cache without a player - if (Player.m_localPlayer == null) + if (biome == Heightmap.Biome.None || biome == Heightmap.Biome.All) { + // Not a real place. A caller or a config asked for something that cannot exist. + EpicLoot.LogErrorForce($"Asked for an adventure spawn point in biome '{BiomeDataManager.GetName(biome)}', " + + "which is not a single biome. This is a configuration or caller error."); + Fail(biome, "$mod_epicloot_adventure_locatefailed", onComplete); yield break; } - yield return FillBiomesBelowTarget(); - } + WorldBiomeIndex.EnsureBuilt(); - /// - /// Kicks off a background top-up when any biome has fallen below the low-water mark. Fire and - /// forget: the caller never waits on it, so consuming a point never blocks on a search. - /// - public static void RequestRefill(bool ignoreThreshold = false) - { - if (_refilling || Player.m_localPlayer == null) + // unscaledTime so a paused or time-scaled game still times out, and so the deadline is + // not affected by the frame rate the way a frame counter would be. + float deadline = Time.unscaledTime + IndexWaitTimeoutSeconds; + while (WorldBiomeIndex.State == BiomeIndexState.Building && Time.unscaledTime < deadline) { - return; + yield return null; } - if (!ignoreThreshold && !AnyBiomeBelowThreshold()) + if (WorldBiomeIndex.State != BiomeIndexState.Ready) { - return; + EpicLoot.LogWarningForce("Cannot pick an adventure spawn point: the world biome index " + + $"is {WorldBiomeIndex.State} after waiting {IndexWaitTimeoutSeconds:0}s."); + Fail(biome, "$mod_epicloot_adventure_locateunavailable", onComplete); + yield break; } - AdventureCacheDriver.Run(RefillCache()); - } + GetRadiusBand(biome, out float minRadius, out float maxRadius); - private static bool AnyBiomeBelowThreshold() - { - foreach (var biome in AdventureDataManager.Config.TreasureMap.GetBiomeList()) + // Preferred: a point inside the configured band, which is what gates a biome's distance + // from the world centre and therefore how far a player has to travel for it. + if (WorldBiomeIndex.TryFindPoint(biome, minRadius, maxRadius, true, + out Vector3 point, out int candidates)) { - if (biome == Heightmap.Biome.None || biome == Heightmap.Biome.All) - { - continue; - } - - if (CachedPointCount(biome) < RefillThreshold) - { - return true; - } + EpicLoot.Log($"Picked {BiomeDataManager.GetName(biome)} spawn at ({point.x:0}, {point.z:0}) " + + $"r={new Vector2(point.x, point.z).magnitude:0} ({candidates} candidates)"); + onComplete?.Invoke(true, point); + yield break; } - return false; - } - - private static IEnumerator RefillCache() - { - _refilling = true; - try - { - yield return FillBiomesBelowTarget(); - } - finally + // The band and the biome do not overlap on this world. Take the nearest match anywhere + // rather than refusing: a player who paid for this would rather travel oddly far than get + // nothing. The warning is the signal that the bands need retuning for this world. + if (WorldBiomeIndex.TryFindPoint(biome, minRadius, maxRadius, false, out point, out candidates)) { - _refilling = false; + float radius = new Vector2(point.x, point.z).magnitude; + EpicLoot.LogWarning($"No usable {BiomeDataManager.GetName(biome)} point inside the configured " + + $"{minRadius:0}-{maxRadius:0}m band ({WorldExtent.Describe()}); " + + $"using the nearest at {radius:0}m instead ({candidates} candidates)."); + onComplete?.Invoke(true, point); + yield break; } - } - - private static IEnumerator FillBiomesBelowTarget() - { - Dictionary> radiusRanges = GetRadiusRanges(); - foreach (var biome in AdventureDataManager.Config.TreasureMap.GetBiomeList()) - { - if (biome == Heightmap.Biome.None || biome == Heightmap.Biome.All) - { - continue; - } - - // Already stocked. Points do not go stale -- the world does not change -- so there is - // nothing to refresh and this is the path a repeat merchant visit takes. - if (CachedPointCount(biome) >= TargetPointsPerBiome) - { - continue; - } - - yield return AddBiomePointLazyCache(radiusRanges, biome); - } - } - - public static IEnumerator AddBiomePointLazyCache(Dictionary> radiusRanges, - Heightmap.Biome biome, bool requireSelection = false, - Action onComplete = null) - { - int tries = 0; - int maxTries = requireSelection ? MaxTriesOnDemand : MaxTriesPerBiomeFill; - - while (true) - { - // Fail safe, exit coroutine. - if (tries > maxTries) - { - onComplete?.Invoke(false, Vector3.zero); - yield break; - } - - // Prevent locking main thread. Every path below increments tries, so this is guaranteed - // to come around -- it previously could not, because the radius-reject path used to - // `continue` without incrementing, leaving the loop spinning with nothing to yield on. - if (tries % 10 == 0 && tries > 1) - { - yield return new WaitForSeconds(1f); - } - - // Stop filling the moment the biome is stocked, rather than burning the full try budget. - if (!requireSelection && CachedPointCount(biome) >= TargetPointsPerBiome) - { - yield break; - } - - var range = radiusRanges.ContainsKey(biome) ? radiusRanges[biome] : - new Tuple(0f, WorldGenerator.waterEdge); - var spawnPoint = SelectWorldPoint(range, tries, biome); - - // Cheap rejection first. WorldGenerator answers biome and height from the world seed with - // no instantiation at all, and it is the same data the Heightmap is built from -- so any - // point it rejects would have failed the full check below too. This is what keeps the - // zone spawn underneath from running on nearly every candidate. - if (!PassesWorldGenPreFilter(spawnPoint)) - { - tries++; - continue; - } - - // The full check needs a real Heightmap (GetGroundData raycasts terrain colliders, and - // the lava test reads the vegetation mask), so the zone has to exist for it. - var zoneId = ZoneSystem.GetZone(spawnPoint); - GameObject zoneRoot = null; - int zoneWaitFrames = 0; - while (!ZoneSystem.instance.SpawnZone(zoneId, ZoneSystem.SpawnMode.Client, out zoneRoot)) - { - // A zone whose location prefab has not finished loading can stay unavailable for an - // open-ended time. Give up on this candidate rather than waiting on it forever. - if (++zoneWaitFrames > MaxZoneWaitFrames) - { - break; - } - - yield return new WaitForEndOfFrame(); - } - - if (zoneRoot == null) - { - tries++; - continue; - } - - Heightmap.Biome spawnLocationBiome; - bool valid; - try - { - valid = IsSpawnLocationValid(spawnPoint, out spawnLocationBiome); - } - finally - { - // SpawnZone always instantiates a fresh root and, unlike PokeLocalZone, we never - // register it in ZoneSystem.m_zones -- so UpdateTTL will never destroy it. Left alone - // this leaked a zone and its terrain mesh on every single try. - UnityEngine.Object.Destroy(zoneRoot); - } - - if (!valid) - { - tries++; - continue; - } - - if (requireSelection && spawnLocationBiome == biome) - { - EpicLoot.Log($"Returning callback for Add Biome valid location: {biome} at {spawnPoint}"); - spawnPoint.y += 100f; - onComplete?.Invoke(true, spawnPoint); - yield break; - } - - if (radiusRanges.ContainsKey(spawnLocationBiome)) - { - var min = radiusRanges[spawnLocationBiome].Item1; - var max = radiusRanges[spawnLocationBiome].Item2; - var mag = new Vector2(spawnPoint.x, spawnPoint.z).magnitude; - if (mag < min || mag > max) - { - tries++; - continue; - } - } - - TryAddBiomePoint(spawnLocationBiome, spawnPoint); - tries++; - } + // Say why, not just that. "No usable location" with thousands of cells indexed is a + // very different problem from a biome this world does not contain, and the two were + // previously indistinguishable from the log. + EpicLoot.LogWarningForce($"The world biome index has no usable {BiomeDataManager.GetName(biome)} location at a " + + $"{WorldBiomeIndex.CellSize:0.#}m sample spacing " + + $"({WorldBiomeIndex.CountCells(biome)} cells indexed, band {minRadius:0}-{maxRadius:0}m). " + + WorldBiomeIndex.DescribeRejections(biome, minRadius, maxRadius) + "."); + Fail(biome, "$mod_epicloot_adventure_locatefailed", onComplete); } /// - /// Seed-level rejection of a candidate point, with nothing instantiated. Mirrors the biome and - /// water tests in using WorldGenerator, which is the source - /// the Heightmap is generated from -- so this only ever rejects points the full check rejects. + /// Reports the failure to the player and completes the request. Never leave a caller waiting + /// on a callback that does not come. /// - private static bool PassesWorldGenPreFilter(Vector3 point) + private static void Fail(Heightmap.Biome biome, string token, Action onComplete) { - var worldGenerator = WorldGenerator.instance; - if (worldGenerator == null) - { - // No generator to consult yet; let the full check decide. - return true; - } - - var biome = worldGenerator.GetBiome(point.x, point.z); - if (biome == Heightmap.Biome.None) + var player = Player.m_localPlayer; + if (player != null) { - return false; + string biomeName = Localization.instance.Localize( + BiomeDataManager.GetLocalizationToken(biome)); + player.Message(MessageHud.MessageType.Center, + Localization.instance.Localize(token, biomeName)); } - if (biome != Heightmap.Biome.Ocean && - ZoneSystem.instance.m_waterLevel > worldGenerator.GetHeight(point.x, point.z) + 2f) - { - return false; - } - - return true; - } - - internal static void SelectSpawnPoint(Heightmap.Biome biome, Action onComplete) - { - List locations = PotentialBiomeLocations[biome]; - Vector3 selectedLocation = locations.First(); - locations.RemoveAt(0); - PotentialBiomeLocations[biome] = locations; - - ZoneSystem.instance.GetGroundData( - ref selectedLocation, out var normal, out var foundBiome, out var biomeArea, out var hmap); - selectedLocation.y += 100f; - onComplete?.Invoke(true, selectedLocation); - } - - internal static Vector3 SelectWorldPoint(Tuple range, int intervalRange, Heightmap.Biome biome) - { - var minimumDistance = range.Item1; - var maximumDistance = range.Item2; - - if (biome == Heightmap.Biome.AshLands || biome == Heightmap.Biome.DeepNorth) - { - // For biomes that are situated in specific areas (eg top/bottom of the world) - float direction = 1f; - if (biome == Heightmap.Biome.AshLands) - { - direction = -1f; - } - - float naturalY = UnityEngine.Random.Range(minimumDistance + (intervalRange * 90), - minimumDistance + (intervalRange * 90) + 100f); - float yDirection = naturalY * direction; - float xDirection = UnityEngine.Random.Range(-1f * (minimumDistance / 2), (minimumDistance / 2)); - return new Vector3(xDirection, 0, yDirection); - } - else - { - // For biomes that are scattered throughout the world - var randomPoint = UnityEngine.Random.insideUnitCircle; - var magnitude = Mathf.Lerp(minimumDistance, - maximumDistance, randomPoint.magnitude); - randomPoint = randomPoint * magnitude; - return new Vector3(randomPoint.x, 0, randomPoint.y); - } + onComplete?.Invoke(false, Vector3.zero); } - internal static bool IsSpawnLocationValid(Vector3 location, out Heightmap.Biome biome) + /// + /// The distance-from-world-centre band a biome's spawn points must fall inside. + /// + /// The configured values are absolute metres authored against a vanilla 10km world. On a world + /// resized by Expand World Size they no longer describe where anything is -- AshLands' shipped + /// 8000-10500 band sits in the inner Meadows of a 40km map -- so they are scaled by the real + /// world radius unless the pack author has already retuned them. + /// + internal static void GetRadiusBand(Heightmap.Biome biome, out float min, out float max) { - ZoneSystem.instance.GetGroundData( - ref location, out var normal, out biome, out var biomeArea, out var hmap); + float limit = WorldExtent.TotalRadius; + var biomeConfig = GetBiomeInfoConfig(biome); - if (biome == Heightmap.Biome.None || hmap == null) + if (biomeConfig == null) { - return false; + // Legitimate for a biome nothing sells a map for -- DeepNorth has no BiomeInfo entry + // but does have bounty targets -- so search the whole world rather than refusing. + EpicLoot.LogWarning($"No adventure BiomeInfo entry for {BiomeDataManager.GetName(biome)}; " + + "searching the whole world for a spawn point. Add a TreasureMap.BiomeInfo " + + "entry to gate how far out this biome's bounties and maps are placed."); + min = 0f; + max = WorldExtent.PlayableRadius; + return; } - // Ashlands biome, and location is in lava | Try not to spawn in lava - if (biome == Heightmap.Biome.AshLands && hmap.IsLava(location)) - { - return false; - } + float scale = AdventureDataManager.Config.TreasureMap.ScaleRadiiToWorldSize + ? WorldExtent.RadiusScale + : 1f; - float groundHeight = location.y; - var waterLevel = ZoneSystem.instance.m_waterLevel; - // Small buffer allowing spawns in shallow water - if (biome != Heightmap.Biome.Ocean && ZoneSystem.instance.m_waterLevel > groundHeight + 2f) - { - return false; - } + min = Mathf.Clamp(Mathf.Min(biomeConfig.MinRadius, biomeConfig.MaxRadius) * scale, 0f, limit); + max = Mathf.Clamp(Mathf.Max(biomeConfig.MinRadius, biomeConfig.MaxRadius) * scale, 0f, limit); - // Is too near to player base - if (EffectArea.IsPointInsideArea(location, EffectArea.Type.PlayerBase, - AdventureDataManager.Config.TreasureMap.MinimapAreaRadius)) + if (max <= min) { - return false; + // A band that clamped to nothing, usually a hand-edited config or a MaxRadius left at + // zero. Fall back to the whole world instead of searching an empty annulus. + EpicLoot.LogWarning($"Adventure radius band for {BiomeDataManager.GetName(biome)} is empty after scaling " + + $"({biomeConfig.MinRadius}-{biomeConfig.MaxRadius} x {scale:0.###}); " + + "searching the whole world instead."); + min = 0f; + max = WorldExtent.PlayableRadius; } - - // Is too near to player ward - if (AdventureWardCheck.TryFindNearbyWard(location, - AdventureDataManager.Config.TreasureMap.MinimapAreaRadius, out _)) - { - return false; - } - - return true; } - // TODO: Decide if we want to keep the RadiusInterval and IncreaseRadiusCount configs - /*private static Tuple GetTreasureMapSpawnRadiusRange(Heightmap.Biome biome, AdventureSaveData saveData) - { - var biomeInfoConfig = GetBiomeInfoConfig(biome); - if (biomeInfoConfig == null) - { - EpicLoot.LogError($"Could not get biome info for biome: {biome}!"); - EpicLoot.LogWarning($"> Current BiomeInfo ({AdventureDataManager.Config.TreasureMap.BiomeInfo.Count}):"); - foreach (var biomeInfo in AdventureDataManager.Config.TreasureMap.BiomeInfo) - { - EpicLoot.Log($"- {biomeInfo.Biome}: min:{biomeInfo.MinRadius}, max:{biomeInfo.MaxRadius}"); - } - - return new Tuple(-1, -1); - } - - var minSearchRange = biomeInfoConfig.MinRadius; - var maxSearchRange = biomeInfoConfig.MaxRadius; - var searchBandWidth = AdventureDataManager.Config.TreasureMap.StartRadiusMax - - AdventureDataManager.Config.TreasureMap.StartRadiusMin; - var numberOfBounties = AdventureDataManager.CheatNumberOfBounties >= 0 ? - AdventureDataManager.CheatNumberOfBounties : saveData.NumberOfTreasureMapsOrBountiesStarted; - var increments = (numberOfBounties / AdventureDataManager.Config.TreasureMap.IncreaseRadiusCount) % 20; - var min1 = minSearchRange + - (AdventureDataManager.Config.TreasureMap.StartRadiusMin + - increments * AdventureDataManager.Config.TreasureMap.RadiusInterval); - var max1 = min1 + searchBandWidth; - var min = Mathf.Clamp(min1, minSearchRange, maxSearchRange - searchBandWidth); - var max = Mathf.Clamp(max1, minSearchRange + searchBandWidth, maxSearchRange); - EpicLoot.Log($"Got biome info for biome ({biome}) - " + - $"Overall search range: {minSearchRange}-{maxSearchRange}. " + - $"Current increments: {increments}. " + - $"Current search band: {min}-{max} (width={searchBandWidth})"); - return new Tuple(min, max); - }*/ - private static TreasureMapBiomeInfoConfig GetBiomeInfoConfig(Heightmap.Biome biome) { - return AdventureDataManager.Config.TreasureMap.BiomeInfo.Find(x => x.Biome == biome); - } - } - - /// - /// Host for the background cache top-up. The merchant panel cannot run it -- StoreGui deactivates - /// that object on close, which would stop the coroutine partway -- and a refill is normally - /// requested at the moment a point is consumed, i.e. while the panel is on its way out. - /// - internal class AdventureCacheDriver : MonoBehaviour - { - private static AdventureCacheDriver _instance; - - public static void Run(IEnumerator routine) - { - if (_instance == null) - { - var go = new GameObject("EpicLoot_AdventureCacheDriver"); - UnityEngine.Object.DontDestroyOnLoad(go); - _instance = go.AddComponent(); - } - - _instance.StartCoroutine(routine); - } - - public static void StopAll() - { - if (_instance != null) - { - _instance.StopAllCoroutines(); - } + return AdventureDataManager.Config.TreasureMap.BiomeInfo.Find(x => x.GetBiome() == biome); } } } diff --git a/EpicLoot/src/Adventure/feature/MerchantListPanel.cs b/EpicLoot/src/Adventure/feature/MerchantListPanel.cs index 2a2d6438..e1d9b591 100644 --- a/EpicLoot/src/Adventure/feature/MerchantListPanel.cs +++ b/EpicLoot/src/Adventure/feature/MerchantListPanel.cs @@ -26,6 +26,39 @@ public abstract class MerchantListPanel : IMerchantListPanel where T : Compon protected int _currentInterval = -1; protected int _selectedItemIndex = -1; + /// + /// Guards against a second click while a purchase or accept is still resolving. + /// + /// It expires rather than latching forever. The work it guards runs as a coroutine whose host + /// can go away mid-flight (a world change stops the adventure driver), and a latch with no + /// expiry left the button permanently dead when that happened. Longer than any search can + /// legitimately take, including a first world biome index build on a very large world. + /// + private bool _actionInFlight; + private float _actionStarted; + private const float ActionTimeout = 60f; + + /// + /// Claims the panel's single in-flight action slot. Every path that begins one must pair this + /// with , including the failure paths. + /// + protected bool TryBeginAction() + { + if (_actionInFlight && Time.unscaledTime - _actionStarted <= ActionTimeout) + { + return false; + } + + _actionInFlight = true; + _actionStarted = Time.unscaledTime; + return true; + } + + protected void EndAction() + { + _actionInFlight = false; + } + protected MerchantListPanel(RectTransform list, T elementPrefab, Button button, [CanBeNull] Text refreshTime) { List = list; diff --git a/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs b/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs index 8a2f0174..8fbead69 100644 --- a/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs +++ b/EpicLoot/src/Adventure/feature/TreasureMapListPanel.cs @@ -9,7 +9,6 @@ namespace EpicLoot.Adventure.Feature class TreasureMapListPanel : MerchantListPanel { private readonly MerchantPanel _merchantPanel; - private IEnumerator SpawnTreasureChestCoroutine; public TreasureMapListPanel(MerchantPanel merchantPanel, TreasureMapListElement elementPrefab) : base( @@ -48,11 +47,6 @@ public override void RefreshButton(Currencies playerCurrencies) protected override void OnMainButtonClicked() { - if (SpawnTreasureChestCoroutine != null) - { - return; - } - Player player = Player.m_localPlayer; if (player == null) { @@ -60,29 +54,38 @@ protected override void OnMainButtonClicked() } TreasureMapListElement treasureMap = GetSelectedItem(); - if (treasureMap != null) + if (treasureMap == null || !TryBeginAction()) { - SpawnTreasureChestCoroutine = AdventureDataManager.TreasureMaps - .SpawnTreasureChest(treasureMap.Biome, player, treasureMap.Price, OnSpawnTreasureChest); - player.StartCoroutine(SpawnTreasureChestCoroutine); + return; } + + // Hosted on the adventure driver rather than the Player, so a death, logout or world + // change cannot kill the coroutine before its callback clears the latch. + AdventureCacheDriver.Run(AdventureDataManager.TreasureMaps + .SpawnTreasureChest(treasureMap.Biome, player, treasureMap.Price, OnSpawnTreasureChest)); } private void OnSpawnTreasureChest(int price, bool success, Vector3 position) { - if (success) + // Everything captured here can be gone by the time the callback lands, since the + // coroutine now outlives the player and the store window. + if (success && StoreGui.instance != null) { InventoryManagement.Instance.RemoveItem(MerchantPanel.GetCoinsName(), price); - + if (StoreGui.instance.m_trader != null) { StoreGui.instance.m_trader.OnBought(new Trader.TradeItem { m_price = 0 }); } - StoreGui.instance.m_buyEffects?.Create(Player.m_localPlayer.transform.position, Quaternion.identity); + Player player = Player.m_localPlayer; + if (player != null) + { + StoreGui.instance.m_buyEffects?.Create(player.transform.position, Quaternion.identity); + } } - SpawnTreasureChestCoroutine = null; + EndAction(); } public override void RefreshItems(Currencies currencies) diff --git a/EpicLoot/src/Adventure/feature/TreasureMaps.cs b/EpicLoot/src/Adventure/feature/TreasureMaps.cs index 0e54ea05..6ae4c58f 100755 --- a/EpicLoot/src/Adventure/feature/TreasureMaps.cs +++ b/EpicLoot/src/Adventure/feature/TreasureMaps.cs @@ -1,4 +1,5 @@ -using Jotunn.Managers; +using EpicLoot.Biomes; +using Jotunn.Managers; using System; using System.Collections; using System.Collections.Generic; @@ -34,7 +35,7 @@ public List GetTreasureMaps() AdventureSaveData saveData = Player.m_localPlayer.GetAdventureSaveData(); foreach (Heightmap.Biome biome in Player.m_localPlayer.m_knownBiome) { - string lootTableName = $"TreasureMapChest_{biome}"; + string lootTableName = $"TreasureMapChest_{BiomeDataManager.GetName(biome)}"; bool lootTableExists = LootRoller.GetLootTable(lootTableName).Count > 0; if (!lootTableExists) @@ -43,7 +44,7 @@ public List GetTreasureMaps() } bool purchased = saveData.HasPurchasedTreasureMap(currentInterval, biome); - TreasureMapBiomeInfoConfig cost = AdventureDataManager.Config.TreasureMap.BiomeInfo.Find(x => x.Biome == biome); + TreasureMapBiomeInfoConfig cost = AdventureDataManager.Config.TreasureMap.BiomeInfo.Find(x => x.GetBiome() == biome); if (cost != null && cost.Cost > 0) { results.Add(new TreasureMapItemInfo() @@ -65,9 +66,10 @@ public IEnumerator SpawnTreasureChest(Heightmap.Biome biome, Player player, int AdventureSaveData saveData = player.GetAdventureSaveData(); yield return BountyLocationEarlyCache.TryGetBiomePoint(biome, saveData, (success, spawnPoint) => { - if (success) + // Only report success once the spawner actually exists. Reporting it unconditionally + // charged the player for a map that CreateTreasureSpawner had refused to create. + if (success && CreateTreasureSpawner(biome, spawnPoint, saveData)) { - CreateTreasureSpawner(biome, spawnPoint, saveData); callback?.Invoke(price, true, spawnPoint); } else @@ -77,7 +79,11 @@ public IEnumerator SpawnTreasureChest(Heightmap.Biome biome, Player player, int }); } - private void CreateTreasureSpawner(Heightmap.Biome biome, Vector3 spawnPoint, AdventureSaveData saveData) + /// + /// Records the purchase and spawns the chest's spawner. Returns false when nothing was + /// created, so the caller can leave the player's coins alone. + /// + private bool CreateTreasureSpawner(Heightmap.Biome biome, Vector3 spawnPoint, AdventureSaveData saveData) { TreasureMapChestInfo treasure_details = new TreasureMapChestInfo() { @@ -91,8 +97,8 @@ private void CreateTreasureSpawner(Heightmap.Biome biome, Vector3 spawnPoint, A // spawning regardless used to leave orphan treasure chests with no save record. if (!saveData.PurchasedTreasureMap(treasure_details)) { - EpicLoot.LogWarning($"Treasure map purchase for {biome} was refused by the save data; no chest spawned."); - return; + EpicLoot.LogWarningForce($"Treasure map purchase for {biome} was refused by the save data; no chest spawned."); + return false; } Quaternion rotation = Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f); @@ -106,6 +112,7 @@ private void CreateTreasureSpawner(Heightmap.Biome biome, Vector3 spawnPoint, A Vector3 offset = new Vector3(offset2.x, 0, offset2.y); Minimap.instance.ShowPointOnMap(spawnPoint + offset); + return true; } } } diff --git a/EpicLoot/src/Biomes/BiomeDataConfig.cs b/EpicLoot/src/Biomes/BiomeDataConfig.cs new file mode 100644 index 00000000..b3a1c7d3 --- /dev/null +++ b/EpicLoot/src/Biomes/BiomeDataConfig.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace EpicLoot.Biomes +{ + /// + /// One biome entry from biomedata.json. Vanilla biomes are identified by Name alone (ID optional); + /// biomes added by other mods need the numeric Heightmap.Biome value that mod assigns them. + /// + [Serializable] + public class BiomeEntryConfig + { + /// + /// Letters and digits only. Doubles as the key other configs use ("Biome": "CursedMountain") + /// and as the suffix of derived prefab and loot table names (TreasureMapChest_{Name}, + /// {Name}_{Rarity}_Unidentified, ShardStone_{Name}). + /// + public string Name; + + /// The Heightmap.Biome value. Optional for vanilla names, required for custom biomes. + public int? ID; + + /// + /// Progression order, lowest first. Entries without an Order sort last, in file order. The + /// shipped file uses 10, 20, ... so a patch can slot a biome in between without renumbering. + /// + public int? Order; + + /// + /// Global keys that must all be set for this biome's boss to count as defeated. An empty list + /// means the biome is never gated. + /// + public List BossDefeatedKeys = new List(); + + /// Rich-text color for the biome name in UI, any value ColorUtility.TryParseHtmlString accepts. + public string Color; + + /// + /// Optional display text ($token or literal). Defaults to the vanilla "$biome_(value)" token, + /// which Expand World Data also registers for the biomes it adds. + /// + public string DisplayName; + } + + [Serializable] + public class BiomeDataConfig + { + public List Biomes = new List(); + } +} diff --git a/EpicLoot/src/Biomes/BiomeDataManager.cs b/EpicLoot/src/Biomes/BiomeDataManager.cs new file mode 100644 index 00000000..f3c8086b --- /dev/null +++ b/EpicLoot/src/Biomes/BiomeDataManager.cs @@ -0,0 +1,490 @@ +using EpicLoot.Adventure; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using UnityEngine; + +namespace EpicLoot.Biomes +{ + /// + /// A biome the mod knows about, resolved from biomedata.json or appended from a legacy + /// adventuredata Bounties.Bosses entry. + /// + public sealed class BiomeDefinition + { + public string Name; + public Heightmap.Biome Biome; + /// Position in progression order, 0 first. Not the raw Order value from the file. + public int Index; + public IReadOnlyList BossDefeatedKeys = Array.Empty(); + public string Color = "white"; + public string DisplayName; + public bool IsVanilla; + public bool IsLegacy; + } + + /// + /// The biome registry: the one place that maps biome names to Heightmap.Biome values and back, + /// and that owns biome progression order, boss keys and presentation. Every user-facing config + /// takes biome names as strings and resolves them here, so a biome another mod adds only has to + /// be declared once, in biomedata.json. + /// + public static class BiomeDataManager + { + public static BiomeDataConfig Config = new BiomeDataConfig(); + + #nullable enable + public static event Action? OnBiomeDataInitialized; + #nullable disable + + private static readonly Regex NamePattern = new Regex("^[A-Za-z0-9]+$", RegexOptions.Compiled); + private static readonly Regex DigitsPattern = new Regex("^[0-9]+$", RegexOptions.Compiled); + + private static List _inOrder = new List(); + private static Dictionary _byName = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private static Dictionary _byBiome = + new Dictionary(); + // Flat (biome, key) list in progression order, duplicates kept, so a key shared by two + // biomes (Ocean and BlackForest both use defeated_gdking) keeps the neighbours it has today. + private static List> _bossKeysInOrder = + new List>(); + private static List _legacyBosses = new List(); + private static readonly HashSet _warnedUnresolved = new HashSet(StringComparer.OrdinalIgnoreCase); + private static bool _legacyNoticeLogged; + + public static void Initialize(BiomeDataConfig config) + { + if (config == null) + { + EpicLoot.LogWarning("BiomeDataManager.Initialize called with a null config; keeping the currently loaded biome data."); + return; + } + + Config = config; + _warnedUnresolved.Clear(); + _legacyNoticeLogged = false; + Rebuild(); + EpicLoot.Log($"Biome data loaded, progression order: {string.Join(", ", _inOrder.Select(b => b.Name))}"); + OnBiomeDataInitialized?.Invoke(); + } + + public static BiomeDataConfig GetCFG() + { + return Config; + } + + /// + /// Registers the deprecated adventuredata Bounties.Bosses list. Entries for biomes the registry + /// already defines are ignored; the rest are appended to the end of the order so an un-migrated + /// file keeps working. Rebuilds the lookups without firing , + /// since it is called from the adventure data handler of that very event. + /// + internal static void SetLegacyBosses(IEnumerable bosses) + { + _legacyBosses = bosses?.Where(b => b != null).ToList() ?? new List(); + Rebuild(); + } + + public static IReadOnlyList BiomesInOrder => _inOrder; + + /// Biomes that are not part of the vanilla Heightmap.Biome enum. + public static IEnumerable CustomBiomes => _inOrder.Where(d => !d.IsVanilla); + + /// + /// Resolves a biome name, a vanilla enum name, or a numeric value. Names win over numbers, so + /// "8" is BlackForest but a biome named "8" cannot exist (names need a letter). + /// + public static bool TryResolve(string nameOrId, out Heightmap.Biome biome) + { + biome = Heightmap.Biome.None; + if (string.IsNullOrWhiteSpace(nameOrId)) + { + return false; + } + + string value = nameOrId.Trim(); + if (_byName.TryGetValue(value, out BiomeDefinition def)) + { + biome = def.Biome; + return true; + } + + if (TryParseVanillaName(value, out biome)) + { + return true; + } + + if (int.TryParse(value, out int number) && number >= 0) + { + biome = (Heightmap.Biome)number; + return true; + } + + return false; + } + + /// + /// with a fallback. An unknown value is reported once per config load, + /// not once per lookup, because the merchant panel re-resolves every entry on each refresh. + /// + public static Heightmap.Biome Resolve(string nameOrId, Heightmap.Biome fallback = Heightmap.Biome.None) + { + if (string.IsNullOrWhiteSpace(nameOrId)) + { + return fallback; + } + + if (TryResolve(nameOrId, out Heightmap.Biome biome)) + { + return biome; + } + + if (_warnedUnresolved.Add(nameOrId.Trim())) + { + EpicLoot.LogWarningForce($"Unknown biome '{nameOrId}'. Use a vanilla biome name, a biome " + + $"defined in biomedata.json, or its numeric Heightmap.Biome value."); + } + + return fallback; + } + + public static bool TryGetDefinition(Heightmap.Biome biome, out BiomeDefinition definition) + { + return _byBiome.TryGetValue(biome, out definition); + } + + public static bool IsKnown(Heightmap.Biome biome) + { + return _byBiome.ContainsKey(biome); + } + + /// Registry name, else the enum name, else the number a custom value prints as. + public static string GetName(Heightmap.Biome biome) + { + return _byBiome.TryGetValue(biome, out BiomeDefinition def) ? def.Name : biome.ToString(); + } + + /// + /// Display token for a biome. The fallback is the token vanilla builds for its own biomes and + /// the one Expand World Data registers for the biomes it adds ("biome_1024"), so custom biomes + /// display correctly with no DisplayName at all. + /// + public static string GetLocalizationToken(Heightmap.Biome biome) + { + if (_byBiome.TryGetValue(biome, out BiomeDefinition def) && !string.IsNullOrEmpty(def.DisplayName)) + { + return def.DisplayName; + } + + return $"$biome_{biome.ToString().ToLowerInvariant()}"; + } + + public static string GetColor(Heightmap.Biome biome) + { + return _byBiome.TryGetValue(biome, out BiomeDefinition def) ? def.Color : "white"; + } + + /// Sort key in progression order. None sorts first, unknown biomes last. + public static int GetOrder(Heightmap.Biome biome) + { + if (biome == Heightmap.Biome.None) + { + return -1; + } + + return _byBiome.TryGetValue(biome, out BiomeDefinition def) ? def.Index : int.MaxValue; + } + + public static IReadOnlyList GetBossKeys(Heightmap.Biome biome) + { + return _byBiome.TryGetValue(biome, out BiomeDefinition def) ? def.BossDefeatedKeys : Array.Empty(); + } + + /// True when every boss key of the biome is set. A biome with no keys is never gated. + public static bool HasAllBossKeys(Heightmap.Biome biome) + { + if (ZoneSystem.instance == null) + { + return false; + } + + foreach (string key in GetBossKeys(biome)) + { + if (!ZoneSystem.instance.GetGlobalKey(key)) + { + return false; + } + } + + return true; + } + + /// Every boss key in progression order; a key shared by several biomes appears once per biome. + public static IEnumerable BossKeysInOrder => _bossKeysInOrder.Select(x => x.Value); + + public static Heightmap.Biome GetFirstBiomeForBossKey(string bossKey) + { + if (string.IsNullOrEmpty(bossKey)) + { + return Heightmap.Biome.None; + } + + foreach (KeyValuePair entry in _bossKeysInOrder) + { + if (entry.Value == bossKey) + { + return entry.Key; + } + } + + return Heightmap.Biome.None; + } + + /// The boss key one step earlier in progression order, or null for the first key or an unknown one. + public static string GetPrevBossKey(string bossKey) + { + if (string.IsNullOrEmpty(bossKey)) + { + return null; + } + + int index = _bossKeysInOrder.FindIndex(x => x.Value == bossKey); + return index <= 0 ? null : _bossKeysInOrder[index - 1].Value; + } + + public static bool IsVanillaValue(Heightmap.Biome biome) + { + return biome != Heightmap.Biome.None && biome != Heightmap.Biome.All && + Enum.IsDefined(typeof(Heightmap.Biome), biome); + } + + private static void Rebuild() + { + var accepted = new List<(BiomeDefinition def, int order, int position)>(); + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var usedBiomes = new HashSet(); + + List entries = Config?.Biomes ?? new List(); + for (int i = 0; i < entries.Count; i++) + { + int position = i + 1; + if (!TryBuildDefinition(entries[i], position, out BiomeDefinition def, out int order)) + { + continue; + } + + if (!usedNames.Add(def.Name)) + { + Warn($"biomedata.json entry #{position}: duplicate biome name '{def.Name}', keeping the first one."); + continue; + } + + if (!usedBiomes.Add(def.Biome)) + { + Warn($"biomedata.json entry #{position}: duplicate biome ID {(int)def.Biome} ('{def.Name}'), keeping the first one."); + continue; + } + + accepted.Add((def, order, position)); + } + + _inOrder = accepted.OrderBy(x => x.order).ThenBy(x => x.position).Select(x => x.def).ToList(); + _byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + _byBiome = new Dictionary(); + foreach (BiomeDefinition def in _inOrder) + { + _byName[def.Name] = def; + _byBiome[def.Biome] = def; + } + + MergeLegacyBosses(); + + for (int i = 0; i < _inOrder.Count; i++) + { + _inOrder[i].Index = i; + } + + _bossKeysInOrder = _inOrder + .SelectMany(def => def.BossDefeatedKeys.Select(key => new KeyValuePair(def.Biome, key))) + .ToList(); + } + + private static void MergeLegacyBosses() + { + var ignored = new List(); + foreach (BountyBossConfig legacy in _legacyBosses) + { + string raw = legacy.Biome?.Trim(); + if (string.IsNullOrEmpty(raw)) + { + continue; + } + + if (!TryResolve(raw, out Heightmap.Biome biome) || biome == Heightmap.Biome.None || biome == Heightmap.Biome.All) + { + Warn($"adventuredata.json Bounties.Bosses entry '{raw}' does not name a known biome; skipped."); + continue; + } + + if (_byBiome.ContainsKey(biome)) + { + ignored.Add(raw); + continue; + } + + bool isVanilla = IsVanillaValue(biome); + var def = new BiomeDefinition + { + Name = isVanilla ? biome.ToString() : ((int)biome).ToString(), + Biome = biome, + BossDefeatedKeys = string.IsNullOrWhiteSpace(legacy.BossDefeatedKey) ? + new List() : new List { legacy.BossDefeatedKey.Trim() }, + IsVanilla = isVanilla, + IsLegacy = true + }; + _inOrder.Add(def); + _byName[def.Name] = def; + _byBiome[def.Biome] = def; + EpicLoot.LogWarning($"adventuredata.json Bounties.Bosses entry '{raw}' is not in biomedata.json and was " + + $"appended to the end of the biome order. Bounties.Bosses is deprecated; define the biome in biomedata.json."); + } + + if (ignored.Count > 0 && !_legacyNoticeLogged) + { + _legacyNoticeLogged = true; + EpicLoot.Log($"adventuredata.json Bounties.Bosses is deprecated; its entries for {string.Join(", ", ignored)} " + + $"are ignored because biome order and boss keys now come from biomedata.json."); + } + } + + private static bool TryBuildDefinition(BiomeEntryConfig entry, int position, out BiomeDefinition def, out int order) + { + def = null; + order = int.MaxValue; + + if (entry == null) + { + Warn($"biomedata.json entry #{position} is empty; skipped."); + return false; + } + + string name = entry.Name?.Trim(); + if (string.IsNullOrEmpty(name)) + { + Warn($"biomedata.json entry #{position} has no Name; skipped."); + return false; + } + + if (!NamePattern.IsMatch(name) || DigitsPattern.IsMatch(name)) + { + Warn($"biomedata.json entry #{position}: biome name '{name}' must be letters and digits only " + + $"(no spaces or underscores) with at least one letter; skipped."); + return false; + } + + Heightmap.Biome biome; + bool isVanilla; + if (TryParseVanillaName(name, out Heightmap.Biome vanilla)) + { + if (vanilla == Heightmap.Biome.None || vanilla == Heightmap.Biome.All) + { + Warn($"biomedata.json entry #{position}: '{name}' is reserved; skipped."); + return false; + } + + if (entry.ID.HasValue && entry.ID.Value != (int)vanilla) + { + Warn($"biomedata.json entry '{name}': ID {entry.ID.Value} ignored, the vanilla biome {vanilla} is always {(int)vanilla}."); + } + + biome = vanilla; + name = vanilla.ToString(); + isVanilla = true; + } + else + { + if (!entry.ID.HasValue || entry.ID.Value <= 0) + { + Warn($"biomedata.json entry '{name}' is not a vanilla biome and has no ID. A custom biome needs the " + + $"Heightmap.Biome value its biome mod assigns (Expand World Data uses 1024, 2048, 4096, ...); skipped."); + return false; + } + + int id = entry.ID.Value; + if ((id & (id - 1)) != 0) + { + Warn($"biomedata.json entry '{name}': ID {id} is not a power of two. Biome values are single flags; skipped."); + return false; + } + + biome = (Heightmap.Biome)id; + if (IsVanillaValue(biome) || biome == Heightmap.Biome.All) + { + Warn($"biomedata.json entry '{name}': ID {id} is the vanilla biome {biome}; use the name '{biome}' instead; skipped."); + return false; + } + + isVanilla = false; + } + + List keys = (entry.BossDefeatedKeys ?? new List()) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Select(key => key.Trim()) + .Distinct() + .ToList(); + + def = new BiomeDefinition + { + Name = name, + Biome = biome, + BossDefeatedKeys = keys, + Color = ParseColor(entry.Color, name), + DisplayName = string.IsNullOrWhiteSpace(entry.DisplayName) ? null : entry.DisplayName.Trim(), + IsVanilla = isVanilla + }; + order = entry.Order ?? int.MaxValue; + return true; + } + + private static bool TryParseVanillaName(string value, out Heightmap.Biome biome) + { + biome = Heightmap.Biome.None; + if (DigitsPattern.IsMatch(value)) + { + return false; + } + + if (Enum.TryParse(value, true, out Heightmap.Biome parsed) && Enum.IsDefined(typeof(Heightmap.Biome), parsed)) + { + biome = parsed; + return true; + } + + return false; + } + + private static string ParseColor(string color, string biomeName) + { + if (string.IsNullOrWhiteSpace(color)) + { + return "white"; + } + + string trimmed = color.Trim(); + if (ColorUtility.TryParseHtmlString(trimmed, out _)) + { + return trimmed; + } + + Warn($"biomedata.json entry '{biomeName}': Color '{color}' is not a valid HTML color; using white."); + return "white"; + } + + private static void Warn(string message) + { + EpicLoot.LogWarningForce(message); + } + } +} diff --git a/EpicLoot/src/Compatibility/ForeignItemContainers.cs b/EpicLoot/src/Compatibility/ForeignItemContainers.cs new file mode 100644 index 00000000..874b72d0 --- /dev/null +++ b/EpicLoot/src/Compatibility/ForeignItemContainers.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using BepInEx.Bootstrap; +using HarmonyLib; + +namespace EpicLoot.Compatibility; + +/// +/// Detects items that another mod has turned into a container -- a backpack, a quiver, a pouch -- +/// using the Backpacks ItemContainer pattern. Those items already own the "Use" press while the +/// inventory is open (the container's own hint reads "Press [Use] to open"), which is the same gesture +/// the ShardStone socket overlay uses, so EpicLoot has to know to stand aside. +/// +/// Soft in every direction: no assembly reference, no BepInDependency, and any failure degrades to +/// "not a container", which restores the pre-existing behaviour rather than taking anything down. +/// +public static class ForeignItemContainers +{ + // Every ItemContainer namespaces its custom data under the Backpacks GUID, whichever mod actually + // declares the subclass -- BowsBeforeHoes, for one, explicitly forces its bundled ItemDataManager + // copy's _modGuid to this value so its quiver shares that space. + private const string BackpacksGuid = "org.bepinex.plugins.backpacks"; + private const string KeyPrefix = BackpacksGuid + "#"; + private const char CustomDataSeparator = '#'; + private const string ContainerTypeName = "Backpacks.ItemContainer"; + + // ItemDataManager custom-data keys are "#[,][#]" -- + // the assembly is appended only when the type lives outside the assembly that owns that + // ItemDataManager copy, and the sub-key only when the data was added under a named slot. Either way + // the class part identifies a type whose ancestry we can test once and remember for the session. + private static readonly Dictionary ContainerKeys = new Dictionary(); + + private static bool _resolvedBackpacks; + private static bool _backpacksPresent; + + private static bool BackpacksPresent + { + get + { + if (!_resolvedBackpacks) + { + _resolvedBackpacks = true; + _backpacksPresent = Chainloader.PluginInfos.ContainsKey(BackpacksGuid); + } + + return _backpacksPresent; + } + } + + /// + /// True when another mod has attached a container inventory to this item, and therefore owns the + /// inventory "Use" gesture over it. + /// + public static bool HasContainer(ItemDrop.ItemData item) + { + // Free when Backpacks is not installed, which is the overwhelmingly common case. + if (!BackpacksPresent || item?.m_customData == null || item.m_customData.Count == 0) + { + return false; + } + + try + { + foreach (KeyValuePair entry in item.m_customData) + { + if (entry.Key == null || !entry.Key.StartsWith(KeyPrefix, StringComparison.Ordinal)) + { + continue; + } + + if (IsContainerKey(entry.Key)) + { + return true; + } + } + } + catch (Exception e) + { + // Their internals moved, or the item is mid-load. Behave as though there is no container. + EpicLoot.LogWarning($"Could not inspect foreign container data ({e.Message}); " + + "treating the item as having no container."); + return false; + } + + return false; + } + + // The custom data is read directly rather than through ItemExtensions.Data(item, mod) / + // ForeignItemInfo on purpose: that bridge only enumerates data the owning mod has already + // materialised, so it can report nothing depending on load order. The raw keys are always there. + private static bool IsContainerKey(string customDataKey) + { + if (ContainerKeys.TryGetValue(customDataKey, out bool known)) + { + return known; + } + + bool isContainer = false; + + // "#Some.Namespace.Type,SomeAssembly#slot" -> "Some.Namespace.Type,SomeAssembly" + string typeName = customDataKey.Substring(KeyPrefix.Length); + int subKey = typeName.IndexOf(CustomDataSeparator); + if (subKey >= 0) + { + typeName = typeName.Substring(0, subKey); + } + + // TypeByName takes the bare name too, falling back to a scan of the loaded assemblies -- which + // is what a container declared inside Backpacks itself produces, since ItemDataManager drops the + // assembly suffix for its own types. + Type type = typeName.Length == 0 ? null : AccessTools.TypeByName(typeName); + + for (Type ancestor = type; ancestor != null; ancestor = ancestor.BaseType) + { + if (ancestor.FullName == ContainerTypeName) + { + isContainer = true; + break; + } + } + + ContainerKeys[customDataKey] = isContainer; + return isContainer; + } +} diff --git a/EpicLoot/src/Compendium/MagicFontManager.cs b/EpicLoot/src/Compendium/MagicFontManager.cs index f59c2324..36d90fcf 100644 --- a/EpicLoot/src/Compendium/MagicFontManager.cs +++ b/EpicLoot/src/Compendium/MagicFontManager.cs @@ -18,17 +18,17 @@ public enum FontOptions [InternalName("LegacyRuntime")] LegacyRuntime } + // Each entry pairs a font asset with one of ITS OWN material variants. Pairing a font with another + // font's material is the bug this file exists to prevent -- see Apply below. 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-AveriaSerifLibre", "Valheim-AveriaSerifLibre")] AveriaSerifLibre, [TMP_Attributes("Valheim-Rune", "Valheim-Rune")] Rune, - } private class InternalName(string internalName) : Attribute @@ -45,6 +45,9 @@ private class TMP_Attributes(string fontName, string materialName) : Attribute private static readonly Dictionary m_fonts = new(); private static readonly Dictionary m_fontAssets = new(); + private static readonly HashSet _warnedLookups = new HashSet(); + private static readonly HashSet _warnedAtlases = new HashSet(); + public static Font GetFont(FontOptions option) { if (m_fonts.TryGetValue(option, out Font font)) @@ -54,7 +57,14 @@ public static Font GetFont(FontOptions option) Font[] fonts = Resources.FindObjectsOfTypeAll(); Font match = fonts.FirstOrDefault(x => x.name == option.GetAttributeOfType().internalName); - m_fonts[option] = match; + + // A miss is not cached: FindObjectsOfTypeAll only sees loaded objects, so a lookup that runs + // before the asset is loaded would otherwise stay unresolved for the whole session. + if (match != null) + { + m_fonts[option] = match; + } + return match; } @@ -65,14 +75,140 @@ public static TMP_FontData GetTMPFont(TMP_FontOptions option) 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}; + + // A partial resolve is still cached: Apply runs once per compendium line, and the two + // FindObjectsOfTypeAll scans above walk every loaded object. RetryFailedLookups drops these + // entries once per page open, which is where a "not loaded yet" miss gets its second chance. + if (matchFont == null || matchMaterial == null) + { + WarnOnce(_warnedLookups, $"{attributes.fontName}|{attributes.materialName}", + $"{option}: font '{attributes.fontName}' {(matchFont == null ? "NOT FOUND" : "ok")}, " + + $"material '{attributes.materialName}' {(matchMaterial == null ? "NOT FOUND" : "ok")}"); + } + m_fontAssets[option] = data; return data; } + // Drops half-resolved entries so the next call rescans. FindObjectsOfTypeAll only sees loaded + // objects, so a lookup that ran before an asset was loaded would otherwise stay unresolved for the + // session. Called once per compendium page open rather than per line. + public static void RetryFailedLookups() + { + List failed = m_fontAssets + .Where(x => x.Value?.font == null || x.Value.material == null) + .Select(x => x.Key) + .ToList(); + + foreach (TMP_FontOptions option in failed) + { + m_fontAssets.Remove(option); + } + } + + // Applies a font asset and its material together, with the atlas check TMP itself performs. + // + // TextMeshProUGUI.LoadFontAsset() only keeps a shared material whose _MainTex is the font asset's + // current atlas texture, and falls back to the font's own material otherwise. The + // fontSharedMaterial setter does no such check, and TMP_Text.font early-returns when the asset is + // unchanged -- so a material assigned to a live component is never validated. Repeating TMP's check + // here means a font paired with another font's material (the shape of the deleted + // AveriaSansLibre-Bold entry) degrades to unstyled text instead of rendering as garbage. + // + // This is what made compendium group headings render as a few unreadable specks on the first open + // of a session and correctly on every open after: they are the only lines that swap material after + // the element is live, so they were the only ones TMP never got to validate. + // Returns false when the font asset could not be resolved, so a caller that latches a + // "fonts loaded" flag can retry on its next open instead of keeping the default font forever. + public static bool Apply(TMP_Text text, TMP_FontOptions option) + { + if (text == null) + { + return false; + } + + TMP_FontData data = GetTMPFont(option); + if (data?.font == null) + { + return false; + } + + text.font = data.font; + text.fontSharedMaterial = MaterialForCurrentAtlas(data.font, data.material, option); + return true; + } + + private static Material MaterialForCurrentAtlas(TMP_FontAsset font, Material material, TMP_FontOptions option) + { + // atlasTexture dereferences atlasTextures[0] unguarded, and this runs per compendium line. + if (material == null || font.atlasTextures == null || font.atlasTextures.Length == 0) + { + return font.material; + } + + // Public and idempotent; TMP calls it the same way before reading the cached property ids. + ShaderUtilities.GetShaderPropertyIDs(); + + Texture materialAtlas = material.GetTexture(ShaderUtilities.ID_MainTex); + Texture fontAtlas = font.atlasTexture; + if (materialAtlas != null && fontAtlas != null && + materialAtlas.GetInstanceID() == fontAtlas.GetInstanceID()) + { + return material; + } + + WarnOnce(_warnedAtlases, $"{font.name}|{material.name}", + $"{option}: material '{material.name}' is not on the current atlas of font '{font.name}'. " + + $"{Describe(font, material)} Using the font's own material."); + return font.material; + } + + // The state that decides how a glyph is sampled, in one line, so the warning above says enough to + // diagnose a recurrence from a player's log alone. + private static string Describe(TMP_FontAsset font, Material material) + { + ShaderUtilities.GetShaderPropertyIDs(); + + Texture fontAtlas = font == null || font.atlasTextures == null || font.atlasTextures.Length == 0 + ? null + : font.atlasTexture; + Texture materialAtlas = material == null ? null : material.GetTexture(ShaderUtilities.ID_MainTex); + + string fontPart = font == null + ? "font=" + : $"font atlas id={(fontAtlas == null ? 0 : fontAtlas.GetInstanceID())} " + + $"{font.atlasWidth}x{font.atlasHeight} actual={(fontAtlas == null ? "none" : $"{fontAtlas.width}x{fontAtlas.height}")} " + + $"textures={font.atlasTextures.Length} mode={font.atlasPopulationMode}"; + + if (material == null) + { + return $"{fontPart}, material="; + } + + return $"{fontPart}, material atlas id={(materialAtlas == null ? 0 : materialAtlas.GetInstanceID())} " + + $"_TextureWidth={Prop(material, ShaderUtilities.ID_TextureWidth)} " + + $"_TextureHeight={Prop(material, ShaderUtilities.ID_TextureHeight)} " + + $"_GradientScale={Prop(material, ShaderUtilities.ID_GradientScale)} " + + $"_ScaleRatioA={Prop(material, ShaderUtilities.ID_ScaleRatio_A)} " + + $"shader='{material.shader?.name}'"; + } + + private static string Prop(Material material, int id) => + material.HasProperty(id) ? material.GetFloat(id).ToString("0.###") : "n/a"; + + private static void WarnOnce(HashSet seen, string key, string message) + { + if (seen.Add(key)) + { + // Ungated on purpose: this is the diagnostic a player's log needs to explain unreadable text. + EpicLoot.LogWarningForce($"[fonts] {message}"); + } + } + public class TMP_FontData { public TMP_FontAsset font; diff --git a/EpicLoot/src/Compendium/MagicPages.cs b/EpicLoot/src/Compendium/MagicPages.cs index faa4b851..ff15d715 100644 --- a/EpicLoot/src/Compendium/MagicPages.cs +++ b/EpicLoot/src/Compendium/MagicPages.cs @@ -108,6 +108,10 @@ public void OnSearch(string query) public void Reset() { + // One retry per open for anything that missed because its asset was not loaded yet; the lookup + // scans every loaded object, so it must not run per line. + MagicFontManager.RetryFailedLookups(); + compendiumTextArea.SetActive(true); Search.Enable(false); MagicPagesTextArea.Enable(false); diff --git a/EpicLoot/src/Compendium/MagicTextElement.cs b/EpicLoot/src/Compendium/MagicTextElement.cs index aeedf873..81606570 100644 --- a/EpicLoot/src/Compendium/MagicTextElement.cs +++ b/EpicLoot/src/Compendium/MagicTextElement.cs @@ -51,20 +51,11 @@ private MagicTextElement(GameObject source) Enable(true); } - private void ApplyFont(MagicFontManager.TMP_FontOptions option) - { - MagicFontManager.TMP_FontData data = MagicFontManager.GetTMPFont(option); - if (data?.font == null) - { - return; - } - - _text.font = data.font; - if (data.material != null) - { - _text.fontSharedMaterial = data.material; - } - } + // Goes through MagicFontManager rather than assigning fontSharedMaterial directly: TMP only + // validates a shared material against the font's current atlas inside LoadFontAsset(), which the + // setter does not run. + private void ApplyFont(MagicFontManager.TMP_FontOptions option) => + MagicFontManager.Apply(_text, option); public void Resize() { @@ -103,6 +94,8 @@ private void Set(string line) public void Destroy() => UnityEngine.Object.Destroy(_obj); public void Enable(bool enable) => _obj.SetActive(enable); + // No re-measure needed: TMP folds material padding into GenerateTextMesh's vertex/UV math only, + // never into CalculatePreferredValues, so the height Set() computed holds across the swap. public void EnableOutline(bool enable) => ApplyFont(enable ? MagicFontManager.TMP_FontOptions.AveriaSerifLibreOutline : MagicFontManager.TMP_FontOptions.AveriaSerifLibre); diff --git a/EpicLoot/src/Compendium/TreasureBountyTextInfo.cs b/EpicLoot/src/Compendium/TreasureBountyTextInfo.cs index 2c479c33..76e6defd 100644 --- a/EpicLoot/src/Compendium/TreasureBountyTextInfo.cs +++ b/EpicLoot/src/Compendium/TreasureBountyTextInfo.cs @@ -1,4 +1,5 @@ using EpicLoot.Adventure; +using EpicLoot.Biomes; using System.Collections.Generic; using System.Linq; @@ -28,7 +29,7 @@ public override void Build(MagicPages instance) foreach (TreasureMapChestInfo treasureMap in sortedTreasureMaps) { content.Add($" - $mod_epicloot_merchant_treasuremaps: " + - $"$biome_{treasureMap.Biome.ToString().ToLower()} " + + $"{BiomeDataManager.GetLocalizationToken(treasureMap.Biome)} " + $"#{treasureMap.Interval + 1}"); } @@ -56,7 +57,7 @@ public override void Build(MagicPages instance) $"$mod_epicloot_activebounties_classification: " + $"{AdventureDataManager.GetMonsterName(bounty.Target.MonsterID)}, "); - string info = $" $mod_epicloot_activebounties_biome: $biome_{bounty.Biome.ToString().ToLower()}"; + string info = $" $mod_epicloot_activebounties_biome: {BiomeDataManager.GetLocalizationToken(bounty.Biome)}"; string status = ""; switch (bounty.State) @@ -95,26 +96,11 @@ public override void Build(MagicPages instance) public static string GetBiomeColor(Heightmap.Biome biome) { - string biomeColor = "white"; - switch (biome) - { - case Heightmap.Biome.Meadows: biomeColor = "#75d966"; break; - case Heightmap.Biome.BlackForest: biomeColor = "#72a178"; break; - case Heightmap.Biome.Swamp: biomeColor = "#a88a6f"; break; - case Heightmap.Biome.Mountain: biomeColor = "#a3bcd6"; break; - case Heightmap.Biome.Plains: biomeColor = "#d6cea3"; break; - } - - return biomeColor; + return BiomeDataManager.GetColor(biome); } public static float GetBiomeOrder(Heightmap.Biome biome) { - if (biome == Heightmap.Biome.BlackForest) - { - return 1.5f; - } - - return (float)biome; + return BiomeDataManager.GetOrder(biome); } } \ No newline at end of file diff --git a/EpicLoot/src/Config/ELConfig.cs b/EpicLoot/src/Config/ELConfig.cs index a0e4bb31..f25f9272 100755 --- a/EpicLoot/src/Config/ELConfig.cs +++ b/EpicLoot/src/Config/ELConfig.cs @@ -3,6 +3,7 @@ using Common; using EpicLoot.Abilities; using EpicLoot.Adventure; +using EpicLoot.Biomes; using EpicLoot.Crafting; using EpicLoot.CraftingV2; using EpicLoot.GatedItemType; @@ -76,6 +77,7 @@ internal class ELConfig { public static ConfigEntry MythicGiftSlotsAdded; public static ConfigEntry LegendaryGiftSuccessChance; public static ConfigEntry MythicGiftSuccessChance; + public static ConfigEntry SocketOverlayModifier; public static ConfigEntry GlobalDropRateModifier; public static ConfigEntry DeferChestLootRoll; @@ -130,6 +132,7 @@ internal class ELConfig { private static CustomRPC AutoSorterConfigurationRPC; private static CustomRPC ShardStonesRPC; private static CustomRPC ShardStoneConversionsRPC; + private static CustomRPC BiomeDataRPC; private static string LocalizationDir = GetLocalizationDirectoryPath(); private static readonly List LocalizationLanguages = new List() { @@ -288,6 +291,8 @@ public void SetupConfigRPCs() { OnServerRecieveConfigs, OnClientRecieveShardStonesConfigs); ShardStoneConversionsRPC = NetworkManager.Instance.AddRPC("epicloot_shardstoneconversions_RPC", OnServerRecieveConfigs, OnClientRecieveShardStoneConversionsConfigs); + BiomeDataRPC = NetworkManager.Instance.AddRPC("epicloot_biomedata_RPC", + OnServerRecieveConfigs, OnClientRecieveBiomeDataConfigs); } private static void CreateConfigValues() { @@ -483,6 +488,11 @@ private static void CreateConfigValues() { "Percent chance that a Mythic Brokkr's Gift adds its slots. On a failed roll the gift is " + "still consumed and nothing is added.\n" + "Min = 0, Max = 100", new AcceptableValueRange(0f, 100f)); + SocketOverlayModifier = BindClient(SectionSockets, "Socket Overlay Modifier", KeyCode.LeftAlt, + "Hold this key with Use to open the shard slots of an item that another mod has turned " + + "into a container (a backpack, a quiver). Plain Use opens that mod's container instead, " + + "since that is the gesture it already advertises. Items with no such container ignore " + + "this setting and open their shard slots on plain Use as usual."); // 4 - Enchanting Table EnchantingTableUpgradesActive = BindServer(SectionEnchanting, "Upgrades Active", true, @@ -739,6 +749,10 @@ public static void InitializeConfig() { // Must precede materialconversions, whose Initialize fires the event that merges these in. SychronizeConfig("shardstoneconversions.json", ShardStoneConversions.Initialize, ShardStoneConversionsRPC, ShardStoneConversions.GetCFG); + // Biome data first: adventuredata resolves its biome names against it (and merges any legacy + // Bounties.Bosses list into it), and iteminfo takes its biome progression order from it. + SychronizeConfig("biomedata.json", BiomeDataManager.Initialize, + BiomeDataRPC, BiomeDataManager.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); @@ -876,8 +890,9 @@ bool ReloadFromDisk() { } // Registered in call order, so the load-order dependencies InitializeConfig encodes - // (adventuredata before iteminfo, shardstones before shardstoneconversions) still hold on a - // hot reload. Thirteen independent watchers fire in whatever order the OS delivers them. + // (biomedata before adventuredata before iteminfo, shardstones before shardstoneconversions) + // still hold on a hot reload. Fourteen independent watchers fire in whatever order the OS + // delivers them. BaseConfigReloaders.RemoveAll(reloader => reloader.FileName == filename); BaseConfigReloaders.Add((filename, ReloadFromDisk)); @@ -1093,8 +1108,12 @@ private static IEnumerator OnClientRecieveItemNameConfigs(long sender, ZPackage return ApplyClientConfig("itemnames.json", package, MagicItemNames.Initialize); } + private static IEnumerator OnClientRecieveBiomeDataConfigs(long sender, ZPackage package) { + return ApplyClientConfig("biomedata.json", package, BiomeDataManager.Initialize); + } + private static IEnumerator OnClientRecieveAdventureDataConfigs(long sender, ZPackage package) { - // Full Initialize (not UpdateAventureData): the RPC path must fire OnSetupAdventureData and + // Full Initialize: the RPC path must fire OnSetupAdventureData and // rebuild the features exactly like a local reload does, or every API-registered bounty // target / stash item / treasure map silently vanishes the moment a client joins a server. return ApplyClientConfig("adventuredata.json", package, AdventureDataManager.Initialize); diff --git a/EpicLoot/src/Crafting/EnchantCostsConfig.cs b/EpicLoot/src/Crafting/EnchantCostsConfig.cs index 3fa46e18..cb7a3ed9 100644 --- a/EpicLoot/src/Crafting/EnchantCostsConfig.cs +++ b/EpicLoot/src/Crafting/EnchantCostsConfig.cs @@ -31,6 +31,17 @@ public class DisenchantProductsConfig public ItemRarity Rarity; public List ItemTypes = new List(); public List ItemNames = new List(); + + // Matches against the tail of m_shared.m_ammoType, the field Epic Loot uses as an identity channel for + // items it creates (IsRunestone, IsMagicCraftingMaterial, IsShardSlotChisel all read it the same way). + // "ShardStone" catches every (color, rarity) shardstone with one entry per rarity, and keeps catching a + // color added later. The suffix can be as specific as the caller likes -- a full "Yagluth|Mythic|ShardStone" + // singles out one stone -- which is how a per-color or per-boss yield would be expressed. + // + // An entry carrying one of these is treated as more specific than one without, and among those the + // longest matching suffix wins, so a single-stone override beats the blanket entry no matter which + // order they end up in: see EnchantCostsHelper.GetSacrificeProducts. + public List AmmoTypeSuffixes = new List(); public List Products = new List(); } @@ -61,7 +72,7 @@ public class RuneCostConfig [Serializable] public class IdentifyCostConfig { - public Heightmap.Biome Biome; + public string Biome; public Dictionary> CostByRarity = new Dictionary>(); } @@ -69,7 +80,9 @@ public class IdentifyCostConfig public class IdentifyTypeConfig { public string Localization; - public Dictionary> BiomeLootLists = new Dictionary>(); + // Keyed by biome name ("none", "Meadows", or a biomedata.json biome); resolved through the + // registry on lookup so a custom biome cannot make the whole file fail to parse. + public Dictionary> BiomeLootLists = new Dictionary>(); public List Costs = new List(); } @@ -84,6 +97,6 @@ public class EnchantingCostsConfig public List RuneExtractCosts = new List(); public List RuneEtchCosts = new List(); public Dictionary IdentifyTypes = new Dictionary(); - public Dictionary IdentifyCosts = new Dictionary(); + public Dictionary IdentifyCosts = new Dictionary(); } } diff --git a/EpicLoot/src/Crafting/EnchantCostsHelper.cs b/EpicLoot/src/Crafting/EnchantCostsHelper.cs index f37b8581..c92042d8 100755 --- a/EpicLoot/src/Crafting/EnchantCostsHelper.cs +++ b/EpicLoot/src/Crafting/EnchantCostsHelper.cs @@ -4,7 +4,10 @@ using System.Collections.Generic; using System.Linq; using UnityEngine; +using EpicLoot.Biomes; using EpicLoot.CraftingV2; +using EpicLoot.GatedItemType; +using EpicLoot.ShardStones; namespace EpicLoot.Crafting { @@ -34,9 +37,45 @@ public static void Initialize(EnchantingCostsConfig config) } Config = config; + EnsureShardStoneSacrificeProducts(); OnSetupEnchantingCosts?.Invoke(); } + // The shardstone sacrifice yield, as a code-side default. config/enchantcosts.json ships the same five + // entries; these are what a player who declines the config-update prompt gets, because an on-disk copy + // of that file wins over the embedded one indefinitely and nothing else in this class supplies a + // default. Without them a stale file would keep paying out the generic magic-Material yield while the + // shard-to-material conversions that used to be the alternative have already been retired. + private static void EnsureShardStoneSacrificeProducts() + { + foreach (ItemRarity rarity in Enum.GetValues(typeof(ItemRarity)).Cast()) + { + ItemRarity shardRarity = rarity; + + // Per rarity rather than all-or-nothing, so a file carrying only a hand-written override for + // one stone still gets the shipped defaults for everything it does not cover. + if (Config.DisenchantProducts.Exists(x => x?.AmmoTypeSuffixes != null && + x.AmmoTypeSuffixes.Contains(Shards.ShardIndicator) && x.Rarity == shardRarity)) + { + continue; + } + + Config.DisenchantProducts.Add(new DisenchantProductsConfig + { + IsMagic = true, + Rarity = rarity, + ItemTypes = new List { ItemDrop.ItemData.ItemType.Material.ToString() }, + AmmoTypeSuffixes = new List { Shards.ShardIndicator }, + Products = new List + { + new ItemAmountConfig { Item = $"Dust{rarity}", Amount = 2 }, + new ItemAmountConfig { Item = $"Reagent{rarity}", Amount = 2 }, + new ItemAmountConfig { Item = $"Essence{rarity}", Amount = 2 } + } + }); + } + } + public static EnchantingCostsConfig GetCFG() { return Config; @@ -56,7 +95,10 @@ public static List GetSacrificeProducts(ItemDrop.ItemData item bool isUnidentified = item.IsUnidentified(); ItemDrop.ItemData.ItemType type = item.m_shared.m_itemType; string name = item.m_shared.m_name; - DisenchantProductsConfig configEntry = Config.DisenchantProducts.Find(x => { + string ammoType = item.m_shared.m_ammoType; + + bool Matches(DisenchantProductsConfig x) + { // Magic item check doesn't apply for unidentified items, since they are considered magic if (x.IsMagic != isMagic && isUnidentified == false) { @@ -83,8 +125,50 @@ public static List GetSacrificeProducts(ItemDrop.ItemData item return false; } + // This runs over every item in the player's inventory, including ones other mods created, so + // the ammo type can be anything or nothing. + if (x.AmmoTypeSuffixes?.Count > 0 && + (string.IsNullOrEmpty(ammoType) || !x.AmmoTypeSuffixes.Exists(ammoType.EndsWith))) + { + return false; + } + return true; - }); + } + + // An entry naming an ammo-type suffix picks out a specific family of items Epic Loot creates -- + // shardstones today -- so it outranks a generic entry matching only on item type and rarity, which + // every magic Material fits. Specificity rather than declaration order, because nothing guarantees + // that order: a config rewrite, a file patch and API.AddSacrifice all append. For the same reason + // the longest matching suffix wins among ammo-typed entries, so a "Yagluth|Mythic|ShardStone" that + // lands after the blanket "ShardStone" still overrides it. + DisenchantProductsConfig configEntry = null; + int bestSuffixLength = 0; + foreach (DisenchantProductsConfig candidate in Config.DisenchantProducts) + { + if (!(candidate.AmmoTypeSuffixes?.Count > 0) || !Matches(candidate)) + { + continue; + } + + // Matches only passes an ammo-typed entry when a suffix matched, so ammoType is set here. + int longest = 0; + foreach (string suffix in candidate.AmmoTypeSuffixes) + { + if (!string.IsNullOrEmpty(suffix) && ammoType.EndsWith(suffix) && suffix.Length > longest) + { + longest = suffix.Length; + } + } + + if (longest > bestSuffixLength) + { + bestSuffixLength = longest; + configEntry = candidate; + } + } + + configEntry ??= Config.DisenchantProducts.Find(x => !(x.AmmoTypeSuffixes?.Count > 0) && Matches(x)); return configEntry?.Products; } @@ -92,6 +176,15 @@ public static List GetSacrificeProducts(ItemDrop.ItemData item public static List GetSacrificeProducts(bool isMagic, ItemDrop.ItemData.ItemType type, ItemRarity rarity ) { DisenchantProductsConfig configEntry = Config.DisenchantProducts.Find(x => { + // There is no item here to read m_ammoType from, so an entry discriminating on it cannot be + // evaluated -- and must not match by default. LootRoller's materials-instead-of-a-magic-item + // substitution calls this with whatever item type the loot table named, Material included, + // which is exactly what the shardstone entries otherwise fit. + if (x.AmmoTypeSuffixes?.Count > 0) + { + return false; + } + if (x.IsMagic && !isMagic) { return false; @@ -134,19 +227,78 @@ public static List GetEnchantCost(ItemDrop.ItemData item, Item return configEntry?.Cost; } + /// + /// Finds the entry keyed by a biome in a config dictionary whose keys are biome names. Keys are + /// resolved through the registry, so "none", "Meadows" and a biomedata.json name all work. + /// + public static bool TryGetForBiome(Dictionary byBiome, Heightmap.Biome biome, out T value) + { + value = default; + if (byBiome == null) + { + return false; + } + + foreach (KeyValuePair entry in byBiome) + { + if (BiomeDataManager.TryResolve(entry.Key, out Heightmap.Biome keyBiome) && keyBiome == biome) + { + value = entry.Value; + return true; + } + } + + return false; + } + + /// + /// Like , but a biome without an entry of its own falls back to + /// the nearest earlier biome in progression order that has one, ending at None. A custom biome + /// therefore needs no entry of its own to identify at the right tier. A biome the registry does + /// not know at all only falls back to None. + /// + public static bool TryGetForBiomeOrLower(Dictionary byBiome, Heightmap.Biome biome, + out T value, out Heightmap.Biome resolvedBiome) + { + resolvedBiome = biome; + if (TryGetForBiome(byBiome, biome, out value)) + { + return true; + } + + List order = GatedItemTypeHelper.BiomesInOrder; + int start = order.IndexOf(biome); + if (start < 0) + { + start = Math.Min(1, order.Count); + } + + for (int i = start - 1; i >= 0; i--) + { + if (TryGetForBiome(byBiome, order[i], out value)) + { + resolvedBiome = order[i]; + return true; + } + } + + return false; + } + public static List GetIdentifyCosts(string category, ItemRarity rarity, Heightmap.Biome biome) { List totalCost = new List(); - // Add biome-specific costs by rarity if configured - if (Config.IdentifyCosts.TryGetValue(biome, out IdentifyCostConfig biomeConfig) && + // Biome-specific costs by rarity. A biome without an entry of its own (a custom biome, say) + // is charged as the nearest lower biome that has one. + if (TryGetForBiomeOrLower(Config.IdentifyCosts, biome, out IdentifyCostConfig biomeConfig, out _) && biomeConfig.CostByRarity.TryGetValue(rarity, out List rarityCosts)) { totalCost.AddRange(rarityCosts); } else { - EpicLoot.LogWarning($"No identify costs configured for biome {biome} and rarity {rarity}."); + EpicLoot.LogWarning($"No identify costs configured for biome {BiomeDataManager.GetName(biome)} and rarity {rarity}."); } // Add category-specific costs diff --git a/EpicLoot/src/Crafting/EnchantHelper.cs b/EpicLoot/src/Crafting/EnchantHelper.cs index e83c7bf9..a638a57b 100644 --- a/EpicLoot/src/Crafting/EnchantHelper.cs +++ b/EpicLoot/src/Crafting/EnchantHelper.cs @@ -1,4 +1,5 @@ -using EpicLoot.CraftingV2; +using EpicLoot.Biomes; +using EpicLoot.CraftingV2; using System; using System.Collections.Generic; using UnityEngine; @@ -61,17 +62,13 @@ public static List> GetRuneCost(ItemDrop.ItemData it } /// - /// Helper to get the biome from custom unidentified items with the format "{biome}_{rarity}_Unidentified" + /// Helper to get the biome from custom unidentified items with the format "{biome}_{rarity}_Unidentified". + /// The prefix is a registry biome name, so biomes from biomedata.json resolve as well as vanilla ones. /// public static Heightmap.Biome GetBiomeFromUnidentifiedItem(ItemDrop.ItemData item) { string biomeString = item.m_dropPrefab.name.Split('_')[0]; - if (!Enum.TryParse(biomeString, out Heightmap.Biome biome)) - { - biome = Heightmap.Biome.None; - } - - return biome; + return BiomeDataManager.TryResolve(biomeString, out Heightmap.Biome biome) ? biome : Heightmap.Biome.None; } } } diff --git a/EpicLoot/src/CraftingV2/EnchantingUIController.cs b/EpicLoot/src/CraftingV2/EnchantingUIController.cs index 0361cd16..0f0c9726 100644 --- a/EpicLoot/src/CraftingV2/EnchantingUIController.cs +++ b/EpicLoot/src/CraftingV2/EnchantingUIController.cs @@ -1,4 +1,5 @@ +using EpicLoot.Biomes; using EpicLoot.Config; using EpicLoot.Crafting; using EpicLoot.Data; @@ -316,6 +317,8 @@ internal static List GetConversionRecipes(MaterialConvers }; bool hasSomeItems = false; + bool declaresShardCost = false; + bool hasSourceShard = false; foreach (MaterialConversionRequirement requirement in conversion.Resources) { GameObject reqPrefab = ObjectDB.instance.GetItemPrefab(requirement.Item); @@ -356,13 +359,28 @@ internal static List GetConversionRecipes(MaterialConvers Amount = requiredAmount }); + bool costIsShard = costItem.IsShardStone(); + declaresShardCost |= costIsShard; + if (InventoryManagement.Instance.CountItem(costItem) > 0) { hasSomeItems = true; + hasSourceShard |= costIsShard; } } - if (hasSomeItems) + // Owning any one ingredient is enough to list a recipe everywhere else, which is what the + // generic modes want -- you should see the runestone upgrade you are two shards short of. It + // reads as noise in the shard ladder though: every Magic-to-Rare step costs a ShardMagic, so + // carrying one surfaced all eighteen colors whether or not you held a single stone. Here the + // stone being consumed is the thing you are actually shopping for. A recipe that names no + // shard at all (nothing shipped does, but a patch or the API could) falls back rather than + // vanishing with no explanation. + bool listRecipe = conversion.Type == MaterialConversionType.ShardUpgrade && declaresShardCost + ? hasSourceShard + : hasSomeItems; + + if (listRecipe) { result.Add(recipe); } @@ -574,14 +592,23 @@ private static List GetLootTablesForIdentifyStyle(IdentifyTypeConfig List lootTables = new List() { }; - if (!cfg.BiomeLootLists.ContainsKey(allowedBiome)) + // A biome without a loot list of its own (a custom biome, say) uses the nearest lower biome + // that has one; the walk ends at None, which every shipped identify type defines. + if (!EnchantCostsHelper.TryGetForBiomeOrLower(cfg.BiomeLootLists, allowedBiome, + out List lootSetNames, out Heightmap.Biome listBiome)) + { + EpicLoot.LogWarning($"No identify loot lists configured for biome " + + $"{BiomeDataManager.GetName(allowedBiome)} or any lower biome."); + return lootTables; + } + + if (listBiome != allowedBiome) { - // Fallback to the first defined biome loot list if the biome cannot be found. - // This should be set to none in the user configurations for best results. - allowedBiome = cfg.BiomeLootLists.First().Key; + EpicLoot.Log($" - No identify loot list for {BiomeDataManager.GetName(allowedBiome)}, " + + $"using {BiomeDataManager.GetName(listBiome)}"); } - foreach (string lootSetName in cfg.BiomeLootLists[allowedBiome]) + foreach (string lootSetName in lootSetNames) { EpicLoot.Log($" - Checking loot set {lootSetName}"); List lootTable = LootRoller.GetFullyResolvedLootTable(lootSetName); @@ -595,6 +622,40 @@ private static List GetLootTablesForIdentifyStyle(IdentifyTypeConfig return lootTables; } + /// + /// True when progression gating would identify any of the items below the biome printed on it, + /// so the identify panel can say so. + /// + internal static bool IsIdentifyGated(List items) + { + GatedItemTypeMode mode = EpicLoot.GetGatedItemTypeMode(); + if (mode == GatedItemTypeMode.Unlimited || mode == GatedItemTypeMode.PlayerMustKnowRecipe) + { + return false; + } + + foreach (ItemDrop.ItemData item in items) + { + if (item == null || item.m_dropPrefab == null) + { + continue; + } + + Heightmap.Biome biome = EnchantHelper.GetBiomeFromUnidentifiedItem(item); + if (biome == Heightmap.Biome.None) + { + continue; + } + + if (GatedItemTypeHelper.GetCurrentOrLowerBiomeByDefeatedBossSettings(biome, mode) != biome) + { + return true; + } + } + + return false; + } + internal static List LootRollSelectedItems( string filter, List> items, float powerModifier) { @@ -1336,7 +1397,7 @@ internal static List GetDisenchantItems() return InventoryManagement.Instance.GetAllItems() .Where(item => !item.m_equipped && !item.IsRunestone() && (ELConfig.ShowEquippedAndHotbarItemsInSacrificeTab.Value || !boundItems.Contains(item))) - .Where(item => item.IsMagic(out MagicItem magicItem) && magicItem.CanBeDisenchanted()) + .Where(item => item.CanBeDisenchanted()) .Select(item => new InventoryItemListElement() { Item = item }) .ToList(); } @@ -1428,7 +1489,7 @@ internal static List DisenchantItem(ItemDrop.ItemData { bonusRolled = false; List returnedItems = new List(); - if (item.IsMagic(out MagicItem magicItem) && magicItem.CanBeDisenchanted()) + if (item.CanBeDisenchanted() && item.IsMagic(out MagicItem magicItem)) { Tuple featureValues = EnchantingTableUI.instance.SourceTable.GetFeatureCurrentValue( EnchantingFeature.Disenchant); diff --git a/EpicLoot/src/CraftingV2/MaterialConversions.cs b/EpicLoot/src/CraftingV2/MaterialConversions.cs index d528e08e..7e4b3ae5 100644 --- a/EpicLoot/src/CraftingV2/MaterialConversions.cs +++ b/EpicLoot/src/CraftingV2/MaterialConversions.cs @@ -10,7 +10,12 @@ public enum MaterialConversionType { Upgrade, Convert, - Junk + Junk, + // Appended, never reordered or inserted into. ConvertUI casts a mode button's index in its serialized + // ModeButtons list straight to this enum, and both the API shim and the config RPCs round-trip it as an + // int (nothing here configures a StringEnumConverter), so the existing ordinals are part of two wire + // formats. Only the JSON configs spell it by name. + ShardUpgrade } [Serializable] diff --git a/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs b/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs index 47aa0660..379d46cc 100755 --- a/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs +++ b/EpicLoot/src/GamePatches/ItemDrop_Patch_MagicItemTooltip.cs @@ -1,5 +1,7 @@ using System; using System.Text; +using EpicLoot.Compatibility; +using EpicLoot.Config; using EpicLoot.Crafting; using HarmonyLib; using JetBrains.Annotations; @@ -39,7 +41,23 @@ private static string GetSocketOpenHint(ItemDrop.ItemData item) // $KEY_Use would reach the screen literally. $KEY_RTrigger / $KEY_ButtonX only resolve to pad // glyphs while a gamepad is active (Localization looks up "Joy" + the binding name), which is // exactly when this branch is taken. - var keys = ZInput.IsGamepadActive() ? "$KEY_RTrigger + $KEY_ButtonX" : "$KEY_Use"; + // An item another mod turned into a container answers plain Use by opening that container, so + // the socket overlay sits behind a modifier there and the hint has to say so. That modifier has + // no $KEY_ binding of its own, so its name goes in raw ("LeftAlt + E"). + string keys; + if (ZInput.IsGamepadActive()) + { + keys = "$KEY_RTrigger + $KEY_ButtonX"; + } + else if (ForeignItemContainers.HasContainer(item)) + { + keys = $"{ELConfig.SocketOverlayModifier.Value} + $KEY_Use"; + } + else + { + keys = "$KEY_Use"; + } + return $"\n[{keys}] $mod_epicloot_press_socket"; } diff --git a/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs b/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs index f958a1f4..dba08547 100644 --- a/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs +++ b/EpicLoot/src/GatedItemType/GatedItemTypeHelper.cs @@ -1,6 +1,6 @@ using BepInEx; -using EpicLoot.Adventure; using EpicLoot.Adventure.Feature; +using EpicLoot.Biomes; using EpicLoot.General; using Jotunn.Managers; using System.Collections.Generic; @@ -118,32 +118,43 @@ public static void Initialize(ItemInfoConfig config) } } + RebuildBiomeOrder(); + + EpicLoot.Log($"Gated items configured, total registered: {AllItemsWithDetails.Keys.Count}"); + } + + /// + /// Biome progression order and the boss keys per biome come from biomedata.json, with None in + /// front as the "ungated" tier. Rebuilt whenever iteminfo or biomedata reloads; the item tables + /// above are untouched by a biome reload. + /// + public static void RebuildBiomeOrder() + { + BiomesInOrder.Clear(); + BiomesToBossKeys.Clear(); + // Items can be ungated, add a dummy entry to account for this BiomesInOrder.Add(Heightmap.Biome.None); BiomesToBossKeys.Add(Heightmap.Biome.None, new List { }); - foreach (BountyBossConfig boss in AdventureDataManager.Config.Bounties.Bosses) + foreach (BiomeDefinition definition in BiomeDataManager.BiomesInOrder) { - if (!BiomesToBossKeys.ContainsKey(boss.Biome)) + if (BiomesToBossKeys.ContainsKey(definition.Biome)) { - BiomesToBossKeys.Add(boss.Biome, new List { boss.BossDefeatedKey }); - } - else - { - if (!BiomesToBossKeys[boss.Biome].Contains(boss.BossDefeatedKey)) - { - BiomesToBossKeys[boss.Biome].Add(boss.BossDefeatedKey); - } + continue; } - // TODO: make a new user defined data structure to control biome order - if (!BiomesInOrder.Contains(boss.Biome)) - { - BiomesInOrder.Add(boss.Biome); - } + BiomesInOrder.Add(definition.Biome); + BiomesToBossKeys.Add(definition.Biome, new List(definition.BossDefeatedKeys)); } + } - EpicLoot.Log($"Gated items configured, total registered: {AllItemsWithDetails.Keys.Count}"); + public static void OnBiomeDataChanged() + { + if (GatedConfig != null) + { + RebuildBiomeOrder(); + } } public static ItemInfoConfig GetCFG() @@ -543,11 +554,11 @@ public static Heightmap.Biome GetCurrentOrLowerBiomeByDefeatedBossSettings(Heigh if (mode == GatedItemTypeMode.BossKillUnlocksNextBiomeItems) { - int index = BiomesInOrder.IndexOf(resultBiome) + 1; - if (index < BiomesInOrder.Count) - { - resultBiome = BiomesInOrder[index]; - } + // One step past the highest defeated biome, but never past the biome the item was + // dropped for: that tier already had the next-biome benefit applied when the item + // rolled, so advancing again here would upgrade the item past its own name. + int index = System.Math.Min(BiomesInOrder.IndexOf(resultBiome) + 1, BiomesInOrder.IndexOf(biome)); + resultBiome = BiomesInOrder[index]; } return resultBiome; diff --git a/EpicLoot/src/Loot/LootRoller.cs b/EpicLoot/src/Loot/LootRoller.cs index fb4ca3af..7e8dd87d 100644 --- a/EpicLoot/src/Loot/LootRoller.cs +++ b/EpicLoot/src/Loot/LootRoller.cs @@ -1,6 +1,7 @@ using BepInEx; using Common; using EpicLoot.Adventure; +using EpicLoot.Biomes; using EpicLoot.Config; using EpicLoot.Crafting; using EpicLoot.Data; @@ -703,7 +704,7 @@ private static bool TrySpawnBiomeShard(LootDrop lootDrop, ref Vector3 dropPoint, { ZoneSystem.instance.GetGroundData(ref dropPoint, out var _, out var shardBiome, out var _, out var _); - var shardSetName = $"ShardStone_{shardBiome}"; + var shardSetName = $"ShardStone_{BiomeDataManager.GetName(shardBiome)}"; if (!ItemSets.ContainsKey(shardSetName)) { EpicLoot.LogWarning($"No shard stone item set found for biome {shardBiome} " + @@ -765,13 +766,10 @@ private static bool TrySpawnUnidentified(LootDrop lootDrop, ref Vector3 dropPoin { foreach (string bosskey in itemDetails.RequiredBosses) { - foreach (BountyBossConfig bossEntry in AdventureDataManager.Config.Bounties.Bosses) + Heightmap.Biome bossBiome = BiomeDataManager.GetFirstBiomeForBossKey(bosskey); + if (bossBiome != Heightmap.Biome.None) { - if (bossEntry.BossDefeatedKey != bosskey) - { - continue; - } - biomes.Add(bossEntry.Biome); + biomes.Add(bossBiome); } } } @@ -782,7 +780,7 @@ private static bool TrySpawnUnidentified(LootDrop lootDrop, ref Vector3 dropPoin biomes.Add(biome); } - var selectBiome = biomes.First().ToString(); + var selectBiome = BiomeDataManager.GetName(biomes.First()); var prefab = ObjectDB.instance.GetItemPrefab($"{selectBiome}_{rarity}_Unidentified"); if (prefab == null) { diff --git a/EpicLoot/src/Magic/ItemDataExtensions.cs b/EpicLoot/src/Magic/ItemDataExtensions.cs index e3da2884..dbe625ac 100644 --- a/EpicLoot/src/Magic/ItemDataExtensions.cs +++ b/EpicLoot/src/Magic/ItemDataExtensions.cs @@ -222,6 +222,21 @@ public static bool CanBeRunified(this ItemDrop.ItemData itemData) .Any(effectDef => effectDef.CanBeRunified); } + // Shardstones and Brokkr's Gifts carry a cosmetic MagicItem -- a rarity and nothing else -- purely + // so they render with a magic name and background. That makes IsMagic() true for them, and + // MagicItem.CanBeDisenchanted() vacuously true as well, since it only vetoes on effects and they + // have none. Disenchanting one would charge the player, hand back nothing and strip the metadata, + // leaving a plain grey consumable. They are not enchanted gear; keep them out of the flow. + public static bool CanBeDisenchanted(this ItemDrop.ItemData itemData) + { + if (itemData == null || itemData.IsShardStone() || itemData.IsShardSlotChisel()) + { + return false; + } + + return itemData.IsMagic(out MagicItem magicItem) && magicItem.CanBeDisenchanted(); + } + public static string GetSetID(this ItemDrop.ItemData itemData, out bool isMundane) { isMundane = true; diff --git a/EpicLoot/src/Patching/FilePatching.cs b/EpicLoot/src/Patching/FilePatching.cs index a7f28fba..4ec8b7e8 100755 --- a/EpicLoot/src/Patching/FilePatching.cs +++ b/EpicLoot/src/Patching/FilePatching.cs @@ -63,6 +63,7 @@ public static class FilePatching "enchantcosts", "itemnames", "itemsorter", + "biomedata", "adventuredata", "legendaries", "abilities", diff --git a/EpicLoot/src/ShardStones/ShardStoneConversions.cs b/EpicLoot/src/ShardStones/ShardStoneConversions.cs index ce6aa7c1..73d7c0ea 100644 --- a/EpicLoot/src/ShardStones/ShardStoneConversions.cs +++ b/EpicLoot/src/ShardStones/ShardStoneConversions.cs @@ -2,13 +2,15 @@ namespace EpicLoot.ShardStones { // Folds config/shardstoneconversions.json into the live material-conversion set, so the ShardStone - // rarity ladder and the shard-to-material sinks show up in the enchanting table's Convert Materials - // tab alongside everything materialconversions.json declares. + // rarity ladder shows up in the enchanting table's Convert Materials tab -- in its own Upgrade Shardstones + // mode, keyed by MaterialConversionType.ShardUpgrade, rather than mixed into the generic material upgrades. + // The shard-to-material sinks this file used to declare now live in config/enchantcosts.json as the + // shardstone sacrifice yield; see Normalize. // - // The recipes are plain MaterialConversion entries -- one per (color, step) and per (color, rarity, - // product) -- written out in full rather than generated from a compact per-category cost model. That - // costs a large file, and buys a config a player or patch author can read, JSONPath-target and reason - // about one shard at a time, and a loot/crafting graph in which every name is a real prefab. + // The recipes are plain MaterialConversion entries -- one per (color, step) -- written out in full rather + // than generated from a compact per-category cost model. That costs a large file, and buys a config a + // player or patch author can read, JSONPath-target and reason about one shard at a time, and a + // loot/crafting graph in which every name is a real prefab. // // They live in their own file purely so materialconversions.json stays hand-readable; the two are one // set as far as the rest of the mod is concerned. @@ -18,11 +20,16 @@ public static class ShardStoneConversions { // re-runs it. private const string NamePrefix = "ShardStone"; + // The two recipe families this file has shipped, distinguished by name. + private const string UpgradePrefix = "ShardStoneUpgrade"; + private const string ConvertPrefix = "ShardStoneConvert"; + public static MaterialConversionsConfig Config; // Config setup hook (SychronizeConfig). public static void Initialize(MaterialConversionsConfig config) { Config = config ?? new MaterialConversionsConfig(); + Normalize(); // OnSetupMaterialConversions only fires when materialconversions.json (re)loads, so merging // here too is what makes a live edit of this file, a config push from a dedicated server, or @@ -57,6 +64,23 @@ public static void Merge() { // True for a recipe this class owns. Also used to keep the merged entries out of the // materialconversions network payload -- every client merges them from its own copy of this file. + // Brings an older copy of this file in line with what this build expects. The embedded config is only + // written to disk when the player has no copy yet, so a returning player -- or a dedicated server + // pushing its own copy -- can still hand us upgrades typed as a plain Upgrade, and the retired + // shard-to-material sinks. Left alone, the former would land back in the generic Upgrade mode this + // change exists to unclutter, and the latter would sit in the Convert mode duplicating what + // sacrificing the same stone now gives. Runs on Config itself, so GetCFG serves normalized data too. + private static void Normalize() { + Config.MaterialConversions.RemoveAll(x => x?.Name != null && x.Name.StartsWith(ConvertPrefix)); + + foreach (MaterialConversion entry in Config.MaterialConversions) { + if (entry?.Name != null && entry.Name.StartsWith(UpgradePrefix) && + entry.Type == MaterialConversionType.Upgrade) { + entry.Type = MaterialConversionType.ShardUpgrade; + } + } + } + public static bool IsShardStoneRecipe(MaterialConversion conversion) { return conversion?.Name != null && conversion.Name.StartsWith(NamePrefix); } diff --git a/EpicLoot/src/ShardStones/SocketsUI.cs b/EpicLoot/src/ShardStones/SocketsUI.cs index 3c0d4263..2ff07b94 100644 --- a/EpicLoot/src/ShardStones/SocketsUI.cs +++ b/EpicLoot/src/ShardStones/SocketsUI.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using EpicLoot.Compatibility; using EpicLoot.Config; using HarmonyLib; using JetBrains.Annotations; @@ -147,7 +148,12 @@ private static void OpenSocketOverlay(InventoryGui invGui, ItemDrop.ItemData ite // Only the keyboard "Use" binding is read here. The gamepad equivalent lives in // InventoryGui_OnRightClickItem_Patch; see the note there for why "JoyUse" cannot be used. [HarmonyPatch(typeof(InventoryGui), nameof(InventoryGui.Update))] + [HarmonyPriority(Priority.First)] public static class InventoryGui_Update_Patch { + // Priority.First: this is a void prefix that only reads input and, at most, consumes it, so + // running ahead of everyone is harmless -- and it is required for the modifier case below. + // A container mod's own Update handler must not open the container out from under a + // deliberate modifier+Use aimed at the socket overlay. [UsedImplicitly] private static void Prefix(InventoryGui __instance) { if (!InventoryGui.IsVisible()) { @@ -173,10 +179,23 @@ private static void Prefix(InventoryGui __instance) { return; } + // Another mod may have made this item a container (a backpack, a BowsBeforeHoes quiver), + // in which case plain Use is already its open gesture and belongs to that mod. Hand the + // press over untouched and put our overlay behind a modifier for those items only -- + // everything else keeps the plain-Use gesture it has always had. + var hasForeignContainer = ForeignItemContainers.HasContainer(item); + if (hasForeignContainer && !Input.GetKey(ELConfig.SocketOverlayModifier.Value)) { + EpicLoot.Log("[SocketsUI] Yielding the Use press over " + + $"{item.m_shared.m_name} to the mod that owns its container."); + return; + } + if (!item.IsMagic(out var magicItem) || !magicItem.HasSockets()) { // Aiming at an item and hitting one without sockets is a miss, not a request to shut // the whole inventory. Swallow the press: Player doesn't read Use while the inventory - // is visible, so nothing else wants it. + // is visible, so nothing else wants it. Reaching here means the press is ours -- a + // container item only gets this far with the modifier held, which is an explicit ask + // for the overlay rather than for the container. if (ELConfig.KeepInventoryOpenOverItems.Value) { ZInput.ResetButtonStatus("Use"); } diff --git a/EpicLoot/src/Tempering/TemperMan.cs b/EpicLoot/src/Tempering/TemperMan.cs index c2bb89e1..e16a3919 100644 --- a/EpicLoot/src/Tempering/TemperMan.cs +++ b/EpicLoot/src/Tempering/TemperMan.cs @@ -9,48 +9,10 @@ public static class TemperMan // The requirements panel is a fixed-height area with no ScrollRect, so only this many rows render. public const int MaxRequirementRows = 4; - private static readonly Dictionary DefaultCostMap = - new Dictionary() - { - [ItemRarity.Magic] = [ - // new TemperRequirement("ShardMagic", 10), - new TemperRequirement("Coins", 10), - new TemperRequirement("EssenceMagic", 10), - new TemperRequirement("ReagentMagic", 10), - new TemperRequirement("DustMagic", 10) - ], - [ItemRarity.Rare] = [ - // new TemperRequirement("ShardRare", 10), - new TemperRequirement("ForestToken", 1), - new TemperRequirement("EssenceRare", 10), - new TemperRequirement("ReagentRare", 10), - new TemperRequirement("DustRare", 10) - ], - [ItemRarity.Epic] = [ - // new TemperRequirement("ShardEpic", 10), - new TemperRequirement("IronBountyToken", 1), - new TemperRequirement("EssenceEpic", 10), - new TemperRequirement("ReagentEpic", 10), - new TemperRequirement("DustEpic", 10) - ], - [ItemRarity.Legendary] = [ - // new TemperRequirement("ShardLegendary", 10), - new TemperRequirement("GoldBountyToken", 1), - new TemperRequirement("EssenceLegendary", 10), - new TemperRequirement("ReagentLegendary", 10), - new TemperRequirement("DustLegendary", 10) - ], - [ItemRarity.Mythic] = [ - // new TemperRequirement("ShardMythic", 10), - new TemperRequirement("GoldBountyToken", 2), - new TemperRequirement("EssenceMythic", 10), - new TemperRequirement("ReagentMythic", 10), - new TemperRequirement("DustMythic", 10) - ], - }; - + // No hardcoded defaults: adventuredata.json's Tempering block is the only source of temper costs, + // so a rarity a config leaves out is simply not temperable. public static Dictionary costMap = - new Dictionary(DefaultCostMap); + new Dictionary(); // Prefabs only resolve once ObjectDB exists, long after the config loads, so an unknown prefab // can only be detected on the UI/affordability path - which runs on every selection change. @@ -58,25 +20,34 @@ public static class TemperMan private static readonly HashSet WarnedInvalidPrefabs = new HashSet(); /// - /// Rebuilds the temper cost table from adventuredata.json's Tempering block. A missing block, or a - /// rarity absent from it, keeps that rarity's hardcoded default. A rarity present with an empty - /// list is taken literally: tempering it is free. + /// Rebuilds the temper cost table from adventuredata.json's Tempering block, which is the only + /// source of temper costs. A rarity absent from the block is not temperable at all - that is how a + /// config turns tempering off for a rarity. A rarity present with an empty list is taken literally: + /// it is temperable and costs nothing. /// public static void ApplyConfig(TemperingConfig config) { WarnedInvalidPrefabs.Clear(); - costMap = new Dictionary(DefaultCostMap); + costMap = new Dictionary(); if (config?.CostsByRarity == null || config.CostsByRarity.Count == 0) { + EpicLoot.LogWarning("Tempering: adventuredata.json has no Tempering.CostsByRarity entries, " + + "so nothing can be tempered. An on-disk adventuredata.json predating the Tempering " + + "block will do this - add the block or delete the file to pick the shipped one back up."); return; } foreach (KeyValuePair> entry in config.CostsByRarity) { ItemRarity rarity = entry.Key; + // A null cost list is malformed rather than empty, so it is treated like an omitted rarity + // (not temperable) instead of like "[]" (temperable, free) - but say so, since the two + // look nearly identical in the json. if (entry.Value == null) { + EpicLoot.LogWarning($"Tempering: rarity {rarity} has a null cost list, treating it as " + + "not temperable. Use [] for a temperable rarity that costs nothing."); continue; } @@ -106,15 +77,16 @@ public static void ApplyConfig(TemperingConfig config) } } + /// + /// Whether tempering applies to this rarity at all. Purely a question of whether the config listed + /// the rarity: an omitted rarity has no costs to charge and its items are kept out of the temper + /// panel entirely, rather than being tempered for free. + /// + public static bool IsTemperableRarity(ItemRarity rarity) => costMap.ContainsKey(rarity); + public static TemperRequirement[] GetRequirements(ItemRarity rarity) { - if (costMap.TryGetValue(rarity, out TemperRequirement[] requirements)) - { - return requirements; - } - return [ - new TemperRequirement("Coins", 10) - ]; + return costMap.TryGetValue(rarity, out TemperRequirement[] requirements) ? requirements : []; } /// diff --git a/EpicLoot/src/Tempering/TemperPanel.cs b/EpicLoot/src/Tempering/TemperPanel.cs index fc9691ee..2b9499d9 100644 --- a/EpicLoot/src/Tempering/TemperPanel.cs +++ b/EpicLoot/src/Tempering/TemperPanel.cs @@ -403,12 +403,17 @@ private bool CanTemper() { } private bool HaveRequirements(Player player) { - if (player.NoCostCheat()) { - return true; - } if (!selectedItemElement) { return false; } + // Checked ahead of the no-cost cheat: a rarity the config left out is not temperable at all, + // so having nothing to pay for it must not become a way to temper it for free. + if (!TemperMan.IsTemperableRarity(selectedItemElement._magicItem.Rarity)) { + return false; + } + if (player.NoCostCheat()) { + return true; + } // Resolved, so unresolvable prefabs are already warned about and dropped upstream. TemperRequirement[] requirements = TemperMan.GetResolvedRequirements(selectedItemElement._magicItem.Rarity); @@ -421,12 +426,15 @@ private bool HaveRequirements(Player player) { } private bool ConsumeRequirements(Player player) { - if (player.NoCostCheat()) { - return true; - } if (!selectedItemElement) { return false; } + if (!TemperMan.IsTemperableRarity(selectedItemElement._magicItem.Rarity)) { + return false; + } + if (player.NoCostCheat()) { + return true; + } // Must be the resolved set: ToPieceRequirement() would hand ConsumeResources a null m_resItem. TemperRequirement[] requirements = TemperMan.GetResolvedRequirements(selectedItemElement._magicItem.Rarity); @@ -442,7 +450,10 @@ public void FillItemList(Player player) { List magicItems = player .GetInventory() .GetAllItems() - .Where(x => x.IsMagic() && x.IsShardStone() == false) + // A rarity with no cost entry in adventuredata.json is not temperable, so its items never + // reach the panel's list. + .Where(x => x.IsMagic() && x.IsShardStone() == false + && TemperMan.IsTemperableRarity(x.GetMagicItem().Rarity)) .ToList(); @@ -867,29 +878,27 @@ private static bool Prefix() { } } + // MagicFontManager.Apply rather than assigning font + fontSharedMaterial directly: TMP only checks + // that a shared material belongs to the font's current atlas inside LoadFontAsset(), which the + // fontSharedMaterial setter never runs. public static void LoadFonts() { if (!fontsLoaded) { - MagicFontManager.TMP_FontData averiaOutline = MagicFontManager.GetTMPFont(MagicFontManager.TMP_FontOptions.AveriaSansLibreOutline); - MagicFontManager.TMP_FontData norseOutline = MagicFontManager.GetTMPFont(MagicFontManager.TMP_FontOptions.NorseBoldOutline); - MagicFontManager.TMP_FontData averia = MagicFontManager.GetTMPFont(MagicFontManager.TMP_FontOptions.AveriaSansLibre); - + bool applied = true; TextMeshProUGUI[] textMeshPros = EpicAssets.TemperPanel.GetComponentsInChildren(true); for (int i = 0; i < textMeshPros.Length; ++i) { TextMeshProUGUI tmp = textMeshPros[i]; if (tmp.name == "Title") { - tmp.font = norseOutline.font; - tmp.fontSharedMaterial = norseOutline.material; - + applied &= MagicFontManager.Apply(tmp, MagicFontManager.TMP_FontOptions.NorseBoldOutline); } else if (tmp.transform.parent.name == "gamepad_hint") { - tmp.font = averia.font; - tmp.fontSharedMaterial = averia.material; + applied &= MagicFontManager.Apply(tmp, MagicFontManager.TMP_FontOptions.AveriaSansLibre); } else { - tmp.font = averiaOutline.font; - tmp.fontSharedMaterial = averiaOutline.material; + applied &= MagicFontManager.Apply(tmp, MagicFontManager.TMP_FontOptions.AveriaSansLibreOutline); } } - fontsLoaded = true; + // Only latched on success: StoreGui reopens call this again, so a lookup that missed + // because the font was not loaded yet gets another go rather than sticking on the default. + fontsLoaded = applied; } } } \ No newline at end of file diff --git a/EpicLoot/src/Terminal/Commands/Adventure.cs b/EpicLoot/src/Terminal/Commands/Adventure.cs index 80a6f0b8..4d99da0b 100644 --- a/EpicLoot/src/Terminal/Commands/Adventure.cs +++ b/EpicLoot/src/Terminal/Commands/Adventure.cs @@ -1,9 +1,10 @@ -using System.Collections; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Text; using EpicLoot.Adventure; using EpicLoot.Adventure.Feature; +using EpicLoot.Biomes; using UnityEngine; using Random = System.Random; @@ -16,7 +17,8 @@ private static void TestTreasureMap(Terminal.ConsoleEventArgs args) Player player = Player.m_localPlayer; int count = args.TryParameterInt(1, 1); - Heightmap.Biome biome = args.GetEnum(2, Heightmap.Biome.None); + // A vanilla name, a biomedata.json name, or a numeric value; blank picks a random map biome. + Heightmap.Biome biome = BiomeDataManager.Resolve(args.GetString(2, ""), Heightmap.Biome.None); int overrideTreasureMapCount = args.TryParameterInt(3, -1); AdventureDataManager.CheatNumberOfBounties = overrideTreasureMapCount; @@ -27,14 +29,18 @@ private static void TestTreasureMap(Terminal.ConsoleEventArgs args) // TODO: update these tests private static IEnumerator TestTreasureMapCoroutine(AdventureSaveData saveData, Heightmap.Biome biome, Player player, int count) { - Heightmap.Biome[] biomes = - [ - Heightmap.Biome.Meadows, - Heightmap.Biome.BlackForest, - Heightmap.Biome.Swamp, - Heightmap.Biome.Mountain, - Heightmap.Biome.Plains - ]; + // Every biome that sells a treasure map, so a biomedata.json biome is covered too. + Heightmap.Biome[] biomes = AdventureDataManager.Config.TreasureMap.BiomeInfo + .Where(x => x.Cost > 0) + .Select(x => x.GetBiome()) + .Where(x => x != Heightmap.Biome.None) + .Distinct() + .ToArray(); + if (biome == Heightmap.Biome.None && biomes.Length == 0) + { + Console.instance.PrintInfo("> No treasure map biomes are configured"); + yield break; + } saveData.DebugMode = true; int startInterval = saveData.TreasureMaps.Count == 0 ? -1 : saveData.TreasureMaps.Min(x => x.Interval) - 1; @@ -114,6 +120,74 @@ private static void ResetAdventure(Terminal.ConsoleEventArgs args) args.Context.PrintInfo("> Cleared adventure data"); } + /// + /// Reports what the adventure spawn picker can actually see. When a player says a bounty or + /// treasure map "did nothing", this is the first thing to run: it shows the resolved world size, + /// whether the biome index built, and per biome how many cells fall inside that biome's + /// configured radius band. A biome with cells but none in band is the failure that used to be + /// silent. + /// + private static void AdventureIndexInfo(Terminal.ConsoleEventArgs args) + { + if (args.Length > 1 && args[1].Equals("rebuild", System.StringComparison.OrdinalIgnoreCase)) + { + WorldBiomeIndex.Reset(); + WorldBiomeIndex.EnsureBuilt(); + args.Context.PrintInfo("> Rebuilding world biome index..."); + return; + } + + WorldBiomeIndex.EnsureBuilt(); + + // A biome name narrows the report to that biome and adds the rejection breakdown, which is + // what answers "the index has cells but the search still fails". + string filter = args.Length > 1 ? args[1] : string.Empty; + Heightmap.Biome filterBiome = Heightmap.Biome.None; + if (!string.IsNullOrEmpty(filter) && !BiomeDataManager.TryResolve(filter, out filterBiome)) + { + args.Context.PrintInfo($"> Unknown biome '{filter}'"); + return; + } + + var sb = new StringBuilder(); + sb.AppendLine($"World extent: {WorldExtent.Describe()}"); + sb.AppendLine($"Biome index: {WorldBiomeIndex.Describe()}"); + + if (WorldBiomeIndex.State != BiomeIndexState.Ready) + { + sb.AppendLine("(index not ready; run again in a moment)"); + args.Context.PrintInfo(sb.ToString()); + return; + } + + // Ordered by progression, with anything the registry does not know about listed after -- a + // biome another mod generates but nobody declared still shows up, which is the case worth + // seeing here. + foreach (Heightmap.Biome biome in WorldBiomeIndex.IndexedBiomes + .Where(x => filterBiome == Heightmap.Biome.None || x == filterBiome) + .OrderBy(x => BiomeDataManager.IsKnown(x) ? BiomeDataManager.GetOrder(x) : int.MaxValue) + .ThenBy(x => BiomeDataManager.GetName(x))) + { + BountyLocationEarlyCache.GetRadiusBand(biome, out float min, out float max); + int inBand = WorldBiomeIndex.CountCellsInBand(biome, min, max); + string warning = inBand == 0 ? " <-- NOTHING IN BAND" : string.Empty; + string unknown = BiomeDataManager.IsKnown(biome) ? string.Empty : " [not in biomedata.json]"; + string water = WorldBiomeIndex.IsOpenWater(biome) ? " [open water]" : string.Empty; + sb.AppendLine($"{BiomeDataManager.GetName(biome)}{unknown}{water}: " + + $"{WorldBiomeIndex.CountCells(biome)} cells, " + + $"{inBand} in band {min:0}-{max:0}m{warning}"); + + // Cells in band is not the same as cells a search can use -- lava, water and the world + // edge all reject in-band cells -- so break that down for the biome the caller named. + if (!string.IsNullOrEmpty(filter)) + { + sb.AppendLine($" {WorldBiomeIndex.DescribeRejections(biome, min, max)}"); + } + } + + args.Context.PrintInfo(sb.ToString()); + } + private static void PrintAvailableBounties(Terminal.ConsoleEventArgs args) { int interval = args.TryParameterInt(1, AdventureDataManager.Bounties.GetCurrentInterval()); diff --git a/EpicLoot/src/Terminal/Commands/Biomes.cs b/EpicLoot/src/Terminal/Commands/Biomes.cs new file mode 100644 index 00000000..c03ca206 --- /dev/null +++ b/EpicLoot/src/Terminal/Commands/Biomes.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using EpicLoot.Biomes; + +namespace EpicLoot; + +public static partial class TerminalManager +{ + private static void PrintBiomes(Terminal.ConsoleEventArgs args) + { + var sb = new StringBuilder(); + sb.AppendLine("Biomes in progression order (biomedata.json):"); + foreach (BiomeDefinition definition in BiomeDataManager.BiomesInOrder) + { + string keys = definition.BossDefeatedKeys.Count == 0 ? "(ungated)" : string.Join(", ", definition.BossDefeatedKeys); + string defeated = BiomeDataManager.HasAllBossKeys(definition.Biome) ? "defeated" : "not defeated"; + string known = Player.m_localPlayer != null && Player.m_localPlayer.m_knownBiome.Contains(definition.Biome) ? + "known" : "not known"; + string origin = definition.IsLegacy ? ", legacy Bounties.Bosses" : definition.IsVanilla ? "" : ", custom"; + string display = Localization.instance.Localize(BiomeDataManager.GetLocalizationToken(definition.Biome)); + sb.AppendLine($"{definition.Index} - {definition.Name} (id {(int)definition.Biome}{origin}): " + + $"keys {keys}, {defeated}, {known}, color {definition.Color}, shown as \"{display}\""); + } + + args.Context.PrintInfo(sb.ToString()); + } + + private static void KnowBiome(Terminal.ConsoleEventArgs args) + { + Player player = Player.m_localPlayer; + if (player == null) + { + args.Context.PrintInfo("> No local player"); + return; + } + + string arg = args.GetString(1, ""); + if (!BiomeDataManager.TryResolve(arg, out Heightmap.Biome biome) || biome == Heightmap.Biome.None) + { + args.Context.PrintInfo($"> Unknown biome '{arg}'. Use 'biomes' to list them."); + return; + } + + // The same path the game takes when the player first walks into a biome, so bounties and + // treasure maps for a custom biome can be tested without the biome mod generating terrain. + player.AddKnownBiome(biome); + args.Context.PrintInfo($"> {BiomeDataManager.GetName(biome)} is now a known biome"); + } + + private static List GetKnowBiomeOptions(string[] args) + { + return args.Length switch + { + 2 => BiomeDataManager.BiomesInOrder.Select(x => x.Name).ToList(), + _ => [] + }; + } +} diff --git a/EpicLoot/src/Terminal/Commands/PrintConfig.cs b/EpicLoot/src/Terminal/Commands/PrintConfig.cs index f3c52817..18ac69eb 100644 --- a/EpicLoot/src/Terminal/Commands/PrintConfig.cs +++ b/EpicLoot/src/Terminal/Commands/PrintConfig.cs @@ -2,6 +2,7 @@ using EpicLoot_UnityLib; using EpicLoot.Abilities; using EpicLoot.Adventure; +using EpicLoot.Biomes; using EpicLoot.Crafting; using EpicLoot.CraftingV2; using EpicLoot.GatedItemType; @@ -16,7 +17,7 @@ public static partial class TerminalManager { private static readonly List ConfigNames = [ - "loottable", "abilities", "adventuredata", "enchantcosts", + "loottable", "abilities", "adventuredata", "biomedata", "enchantcosts", "enchantingupgrades", "iteminfo", "itemnames", "itemsorter", "legendaries", "magiceffects", "materialconversion", "recipes", "shardstones", "shardstoneconversions" @@ -48,6 +49,9 @@ private static void PrintConfig(Terminal.ConsoleEventArgs args) case "adventuredata": EpicLoot.LogWarningForce(JsonConvert.SerializeObject(AdventureDataManager.Config, Formatting.Indented)); break; + case "biomedata": + EpicLoot.LogWarningForce(JsonConvert.SerializeObject(BiomeDataManager.Config, Formatting.Indented)); + break; case "enchantcosts": EpicLoot.LogWarningForce(JsonConvert.SerializeObject(EnchantCostsHelper.Config, Formatting.Indented)); break; diff --git a/EpicLoot/src/Terminal/TerminalManager.cs b/EpicLoot/src/Terminal/TerminalManager.cs index e972feed..8687df9d 100644 --- a/EpicLoot/src/Terminal/TerminalManager.cs +++ b/EpicLoot/src/Terminal/TerminalManager.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using HarmonyLib; namespace EpicLoot; @@ -112,7 +112,10 @@ public static void Postfix() _ = new Command("resetbounties", "removes active bounties", ResetBounties, isCheat: true); _ = new Command("testbountynames", "print randomly generated bounty names: [amount]", TestBountyNames, isCheat: true); _ = new Command("resetadventure", "clear player adventure data", ResetAdventure, isCheat: true); + _ = new Command("adventureindex", "print or rebuild the world biome index: [biome|rebuild]", AdventureIndexInfo, isCheat: true); _ = new Command("bounties", "print available bounties: [interval]", PrintAvailableBounties, isCheat: true); + _ = new Command("biomes", "print biome progression order, boss keys and status", PrintBiomes, isCheat: true); + _ = new Command("knowbiome", "mark a biome as discovered by the local player: [biome]", KnowBiome, GetKnowBiomeOptions, isCheat: true); _ = new Command("playerbounties", "print player available bounties", PrintPlayerAvailableBounties, isCheat: true); _ = new Command("gotomerchant", "teleport to merchant: [merchant]", GoToMerchant, GetGoToOptions, isCheat: true, alternates: "gotom"); _ = new Command("globalkeys", "print active global keys", PrintGlobalKeys, isCheat: true); diff --git a/EpicLoot/src/UI/Enchanting/ConvertUI.cs b/EpicLoot/src/UI/Enchanting/ConvertUI.cs index 9b319bcd..a3386d5f 100644 --- a/EpicLoot/src/UI/Enchanting/ConvertUI.cs +++ b/EpicLoot/src/UI/Enchanting/ConvertUI.cs @@ -42,6 +42,10 @@ public int GetMax() public class ConvertUI : EnchantingTableUIPanelBase { public MultiSelectItemList Products; + + // Order is load-bearing: RefreshMode casts a button's index in this list straight to + // MaterialConversionType, so entry N must be the button for ordinal N. Reordering this in the + // Inspector silently relabels every mode and lists the wrong recipes under each. public List ModeButtons; [Header("Cost")] @@ -83,6 +87,7 @@ public void OnEnable() _mode = 0; RefreshMode(); List items = EnchantingUIController.GetConversionRecipes(_mode); + AvailableItems.ClearFilter(); AvailableItems.SetItems(items.Cast().ToList()); } @@ -122,6 +127,10 @@ public void RefreshMode() public void OnModeChanged() { DeselectAll(); + + // Each mode lists a disjoint set of recipes, so a filter typed against the previous one leaves the + // new list looking empty rather than filtered. SetItems re-applies the filter but never clears it. + AvailableItems.ClearFilter(); RefreshAvailableItems(); switch (_mode) @@ -153,6 +162,15 @@ public void OnModeChanged() _buttonLabel.text = Localization.instance.Localize("$mod_epicloot_junk"); break; + case MaterialConversionType.ShardUpgrade: + CostLabel.text = Localization.instance.Localize("$mod_epicloot_shardupgradecost"); + _progressLabel.text = Localization.instance.Localize("$mod_epicloot_shardupgradeprogress"); + if (_useTMP) + _tmpButtonLabel.text = Localization.instance.Localize("$mod_epicloot_shardupgrade"); + else + _buttonLabel.text = Localization.instance.Localize("$mod_epicloot_shardupgrade"); + break; + default: throw new ArgumentOutOfRangeException(); } @@ -310,5 +328,31 @@ public override void DeselectAll() { AvailableItems.DeselectAll(); } + + public override void Lock() + { + base.Lock(); + SetModeButtonsInteractable(false); + } + + public override void Unlock() + { + base.Unlock(); + SetModeButtonsInteractable(true); + } + + // The gamepad mode cycle in Update already refuses to fire while locked, but a mouse click on a toggle + // does not: it would switch modes mid-countdown, deselect everything, and let the progress bar finish + // on an empty selection. EnchantUI locks its rarity buttons for the same reason. + private void SetModeButtonsInteractable(bool interactable) + { + foreach (Toggle modeButton in ModeButtons) + { + if (modeButton != null) + { + modeButton.interactable = interactable; + } + } + } } } diff --git a/EpicLoot/src/UI/Enchanting/SacrificeUI.cs b/EpicLoot/src/UI/Enchanting/SacrificeUI.cs index 133fa75d..c003cd52 100644 --- a/EpicLoot/src/UI/Enchanting/SacrificeUI.cs +++ b/EpicLoot/src/UI/Enchanting/SacrificeUI.cs @@ -206,10 +206,11 @@ private void IdentifyModeSelected(bool isOn) AvailableItems.ClearFilter(); AvailableItems.SetItems(items.Cast().ToList()); AvailableItems.DeselectAll(); + // Set before the selection refresh, which appends the progress-gated marker when needed. + Explainer.text = Localization.instance.Localize("$mod_epicloot_identify_productsexplainer"); OnSelectedItemsChanged(); Warning.text = Localization.instance.Localize("$mod_epicloot_identify_explain"); Warning.color = new Color(1f, 0.631f, 0.235f); - Explainer.text = Localization.instance.Localize("$mod_epicloot_identify_productsexplainer"); MainButton.GetComponentInChildren().text = Localization.instance.Localize("$mod_epicloot_identify"); IdentifyStylePanel.SetActive(true); CostList.gameObject.SetActive(true); @@ -249,6 +250,13 @@ protected override void OnSelectedItemsChanged() List potentialIdentifyItems = EnchantingUIController.GetPotentialItemRollsByCategory(identifyFilter, selectedItems.Select(x => x.Item1.GetItem()).ToList()); SacrificeProducts.SetItems(potentialIdentifyItems.Cast().ToList()); + + // Say so when progression gating will identify a selected item below its own biome. + bool progressGated = EnchantingUIController.IsIdentifyGated(selectedItems.Select(x => x.Item1.GetItem()).ToList()); + Explainer.text = Localization.instance.Localize(progressGated ? + "$mod_epicloot_identify_productsexplainer $mod_epicloot_identify_progressgated" : + "$mod_epicloot_identify_productsexplainer"); + List> unidentifiedItems = selectedItems.Select( x => new Tuple(x.Item1.GetItem(), x.Item2)).ToList(); Tuple featureValues = diff --git a/EpicLootAPI/EpicLootAPI/src/MaterialConversion.cs b/EpicLootAPI/EpicLootAPI/src/MaterialConversion.cs index 16b88fc7..a294660d 100644 --- a/EpicLootAPI/EpicLootAPI/src/MaterialConversion.cs +++ b/EpicLootAPI/EpicLootAPI/src/MaterialConversion.cs @@ -11,7 +11,10 @@ public enum MaterialConversionType { Upgrade, Convert, - Junk + Junk, + // Mirrors EpicLoot.CraftingV2.MaterialConversionType. Serialized as an int across the API boundary, so + // append only -- never reorder. + ShardUpgrade } [Serializable][PublicAPI] diff --git a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs index 9a291acd..cc1c6531 100644 --- a/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs +++ b/EquipmentAndQuickSlots/EquipmentAndQuickSlots.cs @@ -10,13 +10,17 @@ namespace EquipmentAndQuickSlots { [BepInDependency("moreslots", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("randyknapp.mods.auga", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("randyknapp.mods.epicloot", BepInDependency.DependencyFlags.SoftDependency)] + // Soft dependency purely for load order: Better Archery has to be loaded AND patched + // before this Awake runs, or BetterArcheryCompat has nothing to detect and nothing to + // unpatch (see src/Compatibility/BetterArcheryCompat.cs). + [BepInDependency("ishid4.mods.betterarchery", BepInDependency.DependencyFlags.SoftDependency)] [BepInIncompatibility("Azumatt.AzuExtendedPlayerInventory")] [BepInIncompatibility("aedenthorn.ExtendedPlayerInventory")] [BepInIncompatibility("shudnal.ExtraSlots")] [BepInIncompatibility("com.bruce.valheim.comfyquickslots")] public class EquipmentAndQuickSlots : BaseUnityPlugin { public const string PluginId = "randyknapp.mods.equipmentandquickslots"; - public const string Version = "3.0.1"; + public const string Version = "3.0.2"; public static Sprite PaperdollMale; public static Sprite PaperdollFemale; @@ -42,6 +46,11 @@ private void Awake() { EpicLootCompat.Initialize(); _harmony = Harmony.CreateAndPatchAll(Assembly.GetExecutingAssembly(), PluginId); + + // After CreateAndPatchAll: it needs our Harmony instance to remove Better Archery's + // inventory patches, and it claims reserved slot cells, so it must also run before any + // other mod can take them through the API. + BetterArcheryCompat.Initialize(_harmony); } private void Update() { diff --git a/EquipmentAndQuickSlots/Package/CHANGELOG.md b/EquipmentAndQuickSlots/Package/CHANGELOG.md index 9d2e46b6..b794d733 100644 --- a/EquipmentAndQuickSlots/Package/CHANGELOG.md +++ b/EquipmentAndQuickSlots/Package/CHANGELOG.md @@ -1,3 +1,7 @@ +**3.0.2** +* Better Archery compatibility + + **3.0.1** * Drag-to-unequip works again, with the unequip progress bar * **Compatibility with mods that make the player inventory scrollable** (Valheim Plus with its diff --git a/EquipmentAndQuickSlots/Package/README.md b/EquipmentAndQuickSlots/Package/README.md index bd70623c..02a4fa3e 100644 --- a/EquipmentAndQuickSlots/Package/README.md +++ b/EquipmentAndQuickSlots/Package/README.md @@ -44,6 +44,12 @@ copy `EquipmentAndQuickSlots.dll` into the `BepInEx/plugins` folder. remembers where you put it. `Quick Slots Anchor` / `Quick Slots Position` in this mod's config only decide where the bar starts. If the bar sits somewhere odd after updating from 3.0.0, press F7 and drag it back, or reset BetterUI's `uiData` setting to `none`. +* **Better Archery** works out of the box. Its quiver used to add two hidden rows to the player + inventory, which showed up as a block of empty cells under the inventory and could swallow items. + Its quiver is now hosted as three ammo-only slots in the equipment panel, keyed to Better + Archery's own hotkeys; everything else it does is untouched. Set + `Compatibility / Better Archery quiver integration` to `false` to switch its quiver off entirely + instead and use the quick slots for ammo. * Other slot mods — AzuExtendedPlayerInventory, ExtendedPlayerInventory, ExtraSlots, ComfyQuickSlots — are declared incompatible; BepInEx will not load this mod next to them. diff --git a/EquipmentAndQuickSlots/Package/manifest.json b/EquipmentAndQuickSlots/Package/manifest.json index 01e08768..ca619342 100644 --- a/EquipmentAndQuickSlots/Package/manifest.json +++ b/EquipmentAndQuickSlots/Package/manifest.json @@ -1,6 +1,6 @@ { "name": "EquipmentAndQuickSlots", - "version_number": "3.0.1", + "version_number": "3.0.2", "website_url": "https://github.com/RandyKnapp/ValheimMods/tree/main/EquipmentAndQuickSlots", "description": "Give equipped items their own dedicated inventory slots, plus up to six hotkeyable quick slots.", "dependencies": [ diff --git a/EquipmentAndQuickSlots/src/Compatibility/BetterArcheryCompat.cs b/EquipmentAndQuickSlots/src/Compatibility/BetterArcheryCompat.cs new file mode 100644 index 00000000..b348a435 --- /dev/null +++ b/EquipmentAndQuickSlots/src/Compatibility/BetterArcheryCompat.cs @@ -0,0 +1,256 @@ +using System; +using System.Reflection; +using BepInEx; +using BepInEx.Bootstrap; +using BepInEx.Configuration; +using HarmonyLib; +using UnityEngine; +using static EquipmentAndQuickSlots.Slots; + +namespace EquipmentAndQuickSlots { + // Better Archery (ishid4.mods.betterarchery) does not give the quiver an inventory of its own. + // In a Player.Awake PREFIX it throws away the player's Inventory object and installs a new one + // two rows taller, then treats the first three cells of the last row as the quiver: + // + // Traverse.Create(player).Field("m_inventory") + // .SetValue(new Inventory("Inventory", null, 8, player.m_inventory.m_height + 2)); + // QuiverRowIndex = player.m_inventory.m_height - 1; + // + // A prefix always beats a postfix, so Slots.CaptureBaseRows -- which runs from a Player.Awake + // postfix -- used to latch BaseRows = 6 instead of 4. Both of Better Archery's rows were then + // promoted to "visible" rows that nothing hides and no panel background covers: the block of + // empty cells players reported hanging under the inventory. They were live capacity too, since + // GetEmptySlots counts VisibleRows * width. + // + // Rather than switch the quiver off (what ExtraSlots does), this hosts it: the three quiver + // cells become real EAQS custom slots. Better Archery's only positional contract is + // IsQuiverSlot(x, y) => y == QuiverRowIndex && x >= 0 && x < 3, and reserved slot indices + // 16/17/18 sit at exactly x = 0, 1, 2 of one hidden row, so pointing QuiverRowIndex at that row + // keeps every retained Better Archery code path working -- its hotkeys, its drag rules, its + // unequip guard -- while EAQS owns the cells, the capacity accounting and the death handling. + // + // The patches removed below are the ones that assume it still owns the inventory. Everything + // else Better Archery does (arrow recovery, bow zoom, multishot, the quiver item and its + // visuals) is untouched. + // + // Known rough edge, left alone: the "ba drop" console command drops every item at + // m_gridPos.y >= 5 that is not a quiver slot -- i.e. all EAQS equipment and quick slot + // contents. It is explicitly user-typed, and the same patch registers unrelated commands. + internal static class BetterArcheryCompat { + public const string BetterArcheryGUID = "ishid4.mods.betterarchery"; + private const string PluginTypeName = "BetterArchery.BetterArchery"; + private const string QuiverConfigSection = "Quiver"; + private const string QuiverConfigKey = "Enable Quiver"; + private const string HoldingKeyConfigKey = "Quiver slot hotkey holding key"; + + // The version this shim was written against. Named in the warning when we have to fall + // back, so a report after a Better Archery update points straight at the cause. + private const string KnownVersion = "1.9.8x"; + + // Reserved cells 16-18. Slot index -> grid position is (index % width, VisibleRows + + // index / width), so on a stock 8-wide inventory these are x = 0, 1, 2 of the third hidden + // row -- the shape Better Archery's IsQuiverSlot expects. Verified after claiming, because + // a width-changing mod would break the mapping. + private static readonly int[] QuiverSlotIndices = { 16, 17, 18 }; + private const string QuiverSlotIdPrefix = "BetterArcheryQuiver"; + + private static PluginInfo _plugin; + private static Assembly _assembly; + private static ConfigEntry _quiverEnabled; + private static ConfigEntry _holdingKey; + + private static Func _isQuiverEquipped; + private static Func _getBindingLabel; + private static FieldInfo _quiverRowIndex; + + private static bool _initialized; + private static bool _quiverDisableHooked; + private static bool _quiverStateWarned; + + /// True once the three quiver cells are hosted as EAQS custom slots. + internal static bool QuiverHosted { + get; private set; + } + + public static bool IsLoaded => Chainloader.PluginInfos.ContainsKey(BetterArcheryGUID); + + internal static void Initialize(Harmony harmony) { + if (_initialized) + return; + + _initialized = true; + + if (!Chainloader.PluginInfos.TryGetValue(BetterArcheryGUID, out _plugin) || _plugin?.Instance == null) + return; + + _assembly = _plugin.Instance.GetType().Assembly; + + // Not gated by Better Archery's quiver setting: this postfix rips out every item at + // m_gridPos.y >= 5 and re-adds it or drops it on the ground. Under EAQS that is the + // whole slot region, so it has to go whether or not the quiver is in play. + CompatibilityHelper.RemoveHarmonyPatch(harmony, _assembly, typeof(TombStone), nameof(TombStone.OnTakeAllSuccess), + "BetterArchery.Tombstone+TombStone_OnTakeAllSuccess_Patch", "Postfix", + "stop it dropping equipment and quick slot items when a gravestone is emptied"); + + if (!_plugin.Instance.Config.TryGetEntry(QuiverConfigSection, QuiverConfigKey, out _quiverEnabled)) { + EquipmentAndQuickSlots.LogWarning($"Better Archery is loaded but its '{QuiverConfigKey}' setting could not be read (this logic was written for {KnownVersion}, the installed version is {_plugin.Metadata.Version}). Its quiver may conflict with the equipment and quick slots."); + return; + } + + if (!_quiverEnabled.Value) + return; + + if (!ValConfig.BetterArcheryQuiverIntegration.Value) { + DisableQuiver("the Better Archery quiver integration is turned off in this mod's config"); + return; + } + + if (!BindBetterArchery() || !ClaimQuiverSlots()) { + DisableQuiver("its quiver could not be hosted in the equipment panel"); + return; + } + + QuiverHosted = true; + SyncQuiverRow(); + + CompatibilityHelper.RemoveHarmonyPatch(harmony, _assembly, typeof(Player), nameof(Player.Awake), + "BetterArchery.Player_Awake_Patch", "Prefix", + "stop it replacing the player inventory with a taller one (the empty cells under the inventory)"); + + CompatibilityHelper.RemoveHarmonyPatch(harmony, _assembly, typeof(InventoryGrid), nameof(InventoryGrid.UpdateGui), + "BetterArchery.InventoryGrid_UpdateGui_Patch", "Postfix", + "let the equipment panel own the quiver cells and their positions"); + + CompatibilityHelper.RemoveHarmonyPatch(harmony, _assembly, typeof(Inventory), nameof(Inventory.FindEmptySlot), + "BetterArchery.Inventory_FindEmptySlot_Patch", "Prefix", + "stop it handing out equipment and quick slot cells as free space"); + + CompatibilityHelper.RemoveHarmonyPatch(harmony, _assembly, typeof(Inventory), nameof(Inventory.HaveEmptySlot), + "BetterArchery.Inventory_HaveEmptySlot_Patch", "Prefix", + "stop its hardcoded capacity arithmetic overriding the slot-aware count"); + + CompatibilityHelper.RemoveHarmonyPatch(harmony, _assembly, typeof(Player), nameof(Player.CreateTombStone), + "BetterArchery.Player_CreateTombStone_Patch", "Prefix", + "keep a single gravestone instead of a separate one for the quiver"); + + EquipmentAndQuickSlots.LogInfo($"Better Archery {_plugin.Metadata.Version} detected: its quiver is hosted in three ammo-only slots in the equipment panel."); + } + + private static bool BindBetterArchery() { + Type plugin = _assembly.GetType(PluginTypeName); + if (plugin == null) { + EquipmentAndQuickSlots.LogWarning($"Better Archery is loaded but {PluginTypeName} could not be resolved."); + return false; + } + + _isQuiverEquipped = CompatibilityHelper.BindStatic>(plugin, "IsQuiverEquipped"); + _getBindingLabel = CompatibilityHelper.BindStatic>(plugin, "GetBindingLabel"); + _quiverRowIndex = AccessTools.Field(plugin, "QuiverRowIndex"); + _plugin.Instance.Config.TryGetEntry(QuiverConfigSection, HoldingKeyConfigKey, out _holdingKey); + + if (_isQuiverEquipped != null && _quiverRowIndex != null && _quiverRowIndex.FieldType == typeof(int)) + return true; + + EquipmentAndQuickSlots.LogWarning($"Better Archery's quiver members could not be bound (this logic was written for {KnownVersion}, the installed version is {_plugin.Metadata.Version})."); + return false; + } + + private static bool ClaimQuiverSlots() { + int claimed = 0; + for (int i = 0; i < QuiverSlotIndices.Length; i++) { + int ordinal = i; + if (!TryAddCustomSlotAt(QuiverSlotIndices[i], QuiverSlotIdPrefix + (i + 1), BetterArcheryGUID, + () => SlotLabel(ordinal), IsQuiverAmmo, IsQuiverEquipped)) + break; + + claimed++; + } + + if (claimed == QuiverSlotIndices.Length && QuiverCellsAreWhereBetterArcheryExpects()) + return true; + + // Somebody else holds one of the cells, or a width-changing mod moved them off the + // first three columns. Give back whatever we took: a half-claimed row would strand + // arrows in a cell Better Archery cannot see. + for (int i = 0; i < claimed; i++) + TryRemoveCustomSlot(QuiverSlotIdPrefix + (i + 1)); + + return false; + } + + // Better Archery addresses the quiver as row QuiverRowIndex, columns 0-2. Our cells only + // land there on a stock-width inventory; anything else and the integration is off. + private static bool QuiverCellsAreWhereBetterArcheryExpects() { + int row = slots[QuiverSlotIndices[0]].GridPosition.y; + for (int i = 0; i < QuiverSlotIndices.Length; i++) { + Vector2i pos = slots[QuiverSlotIndices[i]].GridPosition; + if (pos.x != i || pos.y != row) + return false; + } + + return true; + } + + // Slot.IsActive is read every frame from the panel and the validation sweeps, so this may + // not throw. Better Archery's IsQuiverEquipped indexes a Dictionary that is + // filled from a Humanoid.Awake postfix; an entry it never got would take EAQS down with it. + private static bool IsQuiverEquipped() { + if (_isQuiverEquipped == null) + return false; + + try { + return _isQuiverEquipped(); + } catch (Exception ex) { + if (!_quiverStateWarned) { + _quiverStateWarned = true; + EquipmentAndQuickSlots.LogWarning($"Better Archery's IsQuiverEquipped threw; the quiver slots stay hidden: {ex.Message}"); + } + + return false; + } + } + + private static bool IsQuiverAmmo(ItemDrop.ItemData item) => + item != null && item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo; + + // Better Archery draws its own hotkey on the cell; mirror it, holding key included, so the + // label reads the same as it did when Better Archery owned the row. + private static string SlotLabel(int ordinal) { + string key = _getBindingLabel != null ? _getBindingLabel(ordinal) : (ordinal + 1).ToString(); + KeyCode holding = _holdingKey == null ? KeyCode.None : _holdingKey.Value.MainKey; + return holding == KeyCode.None ? key : $"{holding} + {key}"; + } + + /// + /// Points Better Archery's quiver row at the cells we host. Called from + /// Slots.UpdateSlotsGridPosition, so it follows the slot region when the extra-rows config + /// changes. No-op unless the quiver is actually hosted. + /// + internal static void SyncQuiverRow() { + if (!QuiverHosted) + return; + + int row = slots[QuiverSlotIndices[0]].GridPosition.y; + if ((int)_quiverRowIndex.GetValue(null) != row) + _quiverRowIndex.SetValue(null, row); + } + + // The ExtraSlots approach, kept as the fallback: with the quiver off, every Better Archery + // patch that touches the inventory no-ops, and the quick slots cover the same ground. + private static void DisableQuiver(string reason) { + if (_quiverEnabled == null || !_quiverEnabled.Value) + return; + + // Subscribe before the write, and only once: setting Value fires SettingChanged + // synchronously, and a handler added per call would pile up on every re-enable. + if (!_quiverDisableHooked) { + _quiverDisableHooked = true; + _quiverEnabled.SettingChanged += (_, _) => DisableQuiver(reason); + } + + _quiverEnabled.Value = false; + + EquipmentAndQuickSlots.LogWarning($"Better Archery's quiver was disabled because {reason}. Arrows already in it are not lost -- they move into your inventory. Use the quick slots for ammo instead. (This logic was written for Better Archery {KnownVersion}; the installed version is {_plugin.Metadata.Version}.)"); + } + } +} diff --git a/EquipmentAndQuickSlots/src/Compatibility/CompatibilityHelper.cs b/EquipmentAndQuickSlots/src/Compatibility/CompatibilityHelper.cs new file mode 100644 index 00000000..36f24e79 --- /dev/null +++ b/EquipmentAndQuickSlots/src/Compatibility/CompatibilityHelper.cs @@ -0,0 +1,64 @@ +using System; +using System.Reflection; +using HarmonyLib; + +namespace EquipmentAndQuickSlots { + // Shared plumbing for the compatibility shims. Everything here resolves the other mod by name + // through reflection, so no shim ever forces an assembly reference and a missing or renamed + // member degrades to a log line instead of a TypeLoadException. + internal static class CompatibilityHelper { + /// + /// Removes one Harmony patch another mod applied to a vanilla method. Every lookup failure + /// is logged and swallowed: the other mod is free to rename or drop the patch in a later + /// version, and that must not take this mod down with it. + /// + internal static bool RemoveHarmonyPatch(Harmony harmony, Assembly assembly, Type patchedType, string patchedMethod, string patcherClassName, string patcherClassMethod, string reason) { + if (harmony == null || assembly == null) + return false; + + Type patcherType = assembly.GetType(patcherClassName); + if (patcherType == null) { + EquipmentAndQuickSlots.LogInfo($"Compatibility: {patcherClassName} not found; nothing to unpatch"); + return false; + } + + if (AccessTools.Method(patchedType, patchedMethod) is not MethodInfo method) { + EquipmentAndQuickSlots.LogInfo($"Compatibility: {patchedType.Name}.{patchedMethod} not found; nothing to unpatch"); + return false; + } + + if (AccessTools.Method(patcherType, patcherClassMethod) is not MethodInfo patch) { + EquipmentAndQuickSlots.LogInfo($"Compatibility: {patcherClassName}.{patcherClassMethod} not found; nothing to unpatch"); + return false; + } + + try { + harmony.Unpatch(method, patch); + } catch (Exception ex) { + EquipmentAndQuickSlots.LogWarning($"Compatibility: failed to unpatch {patcherClassName}.{patcherClassMethod}: {ex.Message}"); + return false; + } + + EquipmentAndQuickSlots.LogInfo($"Compatibility: unpatched {patcherClassName}.{patcherClassMethod} from {patchedType.Name}.{patchedMethod} to {reason}"); + return true; + } + + /// + /// Binds a static method of another mod to a delegate once, so the call site pays no + /// reflection cost. Null when the method is missing or its signature has changed. + /// + internal static TDelegate BindStatic(Type type, string methodName) where TDelegate : class { + if (type == null) + return null; + + if (AccessTools.Method(type, methodName) is not MethodInfo method || !method.IsStatic) + return null; + + try { + return Delegate.CreateDelegate(typeof(TDelegate), method, throwOnBindFailure: false) as TDelegate; + } catch (Exception) { + return null; + } + } + } +} diff --git a/EquipmentAndQuickSlots/src/InventoryBackup.cs b/EquipmentAndQuickSlots/src/InventoryBackup.cs index c42495fc..741795e0 100644 --- a/EquipmentAndQuickSlots/src/InventoryBackup.cs +++ b/EquipmentAndQuickSlots/src/InventoryBackup.cs @@ -51,6 +51,21 @@ private static string SerializeBackup(Inventory inventory) { return envelope.GetBase64(); } + /// + /// The visible row count in effect when the backup was written. The envelope has carried it + /// since 3.0, which makes it the one durable record of where the slot region used to be for + /// characters saved before the explicit marker existed. + /// + internal static bool TryGetBackupVisibleRows(Player player, out int visibleRows) { + visibleRows = 0; + + if (!TryGetBackup(player, out BackupEnvelope envelope)) + return false; + + visibleRows = envelope.visibleRows; + return visibleRows > 0; + } + private static bool TryGetBackup(Player player, out BackupEnvelope backup) { backup = null; diff --git a/EquipmentAndQuickSlots/src/Migration.cs b/EquipmentAndQuickSlots/src/Migration.cs index fda83132..04cdb1df 100644 --- a/EquipmentAndQuickSlots/src/Migration.cs +++ b/EquipmentAndQuickSlots/src/Migration.cs @@ -1,5 +1,6 @@ using EquipmentAndQuickSlots.src; using HarmonyLib; +using System.Globalization; using System.Linq; using static EquipmentAndQuickSlots.Slots; using static EquipmentAndQuickSlots.src.MiscPatches; @@ -13,6 +14,11 @@ public static class Migration { public const string EquipmentSlotInventoryKey = "EquipmentSlotInventory"; public const string ExtendedPlayerDataKey = "ExtendedPlayerData"; + // The visible row count the character was last saved with. The slot region sits directly + // under the visible grid, so this is what says whether the saved grid positions still mean + // what they meant last session. + public const string VisibleRowsKey = "eaqs_visible_rows"; + private const int LegacyQuickSlotCount = 3; private const int LegacyEquipSlotCount = 5; @@ -110,6 +116,87 @@ private static void RemoveLegacyKey(Player player, string key) { player.m_knownTexts.Remove(TextsDialog_UpdateTextsList_Patch.LegacySentinel + key); } + // The slot rows move whenever the visible row count changes: a config edit, a rows mod + // installed or removed, or -- the case this was written for -- Better Archery stopping + // inflating the player inventory by two rows. Saved items keep the grid position they had, + // so without this the equipment and quick slot contents come back on the wrong rows, or + // past the end of the inventory where nothing can reach them again. + // + // The shift is a bijection over the slot region (everything at or below the old first slot + // row moves by the same delta), so it cannot make two items collide. Anything that was in + // the old visible grid and now finds itself on a slot cell is left to the validation sweep. + internal static void MigrateSlotRegionRows(Player player, int previousVisibleRows) { + int delta = VisibleRows - previousVisibleRows; + Inventory inventory = player.GetInventory(); + if (delta == 0 || inventory == null) + return; + + int moved = 0; + foreach (ItemDrop.ItemData item in inventory.m_inventory) { + if (item.m_gridPos.y < previousVisibleRows) + continue; + + item.m_gridPos = new Vector2i(item.m_gridPos.x, item.m_gridPos.y + delta); + moved++; + } + + if (moved == 0) + return; + + // Record the new count now: a second Load on this same object would otherwise read the + // stale marker and shift everything a second time. + player.m_customData[VisibleRowsKey] = VisibleRows.ToString(CultureInfo.InvariantCulture); + + ClearCachedItems(); + inventory.Changed(); + SlotValidation.ValidateItems(); + SlotValidation.ValidateSlots(); + + // Ungated: this moved the player's gear, and a report about misplaced items has to + // be answerable from the log even with logging turned off. + EquipmentAndQuickSlots.LogInfo($"Visible rows changed {previousVisibleRows} -> {VisibleRows}; moved {moved} item(s) with the slot region"); + } + + private static bool TryGetPreviousVisibleRows(Player player, out int visibleRows) { + if (player.m_customData.TryGetValue(VisibleRowsKey, out string stored) + && int.TryParse(stored, NumberStyles.Integer, CultureInfo.InvariantCulture, out visibleRows) + && visibleRows > 0) + return true; + + // No marker: characters saved before it existed still carry the row count inside the + // slots backup envelope. + return InventoryBackup.TryGetBackupVisibleRows(player, out visibleRows); + } + + [HarmonyPatch(typeof(Player), nameof(Player.Save))] + private static class Player_Save_WriteVisibleRows { + [HarmonyPriority(Priority.Last)] + private static void Prefix(Player __instance) { + if (__instance == CurrentPlayer) + __instance.m_customData[VisibleRowsKey] = VisibleRows.ToString(CultureInfo.InvariantCulture); + } + } + + // Ahead of the backup restore (default priority), so that runs against corrected positions. + [HarmonyPatch(typeof(Player), nameof(Player.Load))] + public static class Player_Load_MigrateSlotRegionRows { + [HarmonyPriority(Priority.High)] + public static void Postfix(Player __instance) { + if (!FejdStartup.instance && !IsValidPlayer(__instance)) + return; + + if (!TryGetPreviousVisibleRows(__instance, out int previousVisibleRows)) + return; + + loadedPlayer = __instance; + try { + MigrateSlotRegionRows(__instance, previousVisibleRows); + } finally { + loadedPlayer = null; + } + } + } + [HarmonyPatch(typeof(Player), nameof(Player.Load))] public static class Player_Load_MigrateLegacyData { [HarmonyPriority(Priority.High)] diff --git a/EquipmentAndQuickSlots/src/Slots.cs b/EquipmentAndQuickSlots/src/Slots.cs index 39ea978f..8ac6614c 100644 --- a/EquipmentAndQuickSlots/src/Slots.cs +++ b/EquipmentAndQuickSlots/src/Slots.cs @@ -592,22 +592,33 @@ void AddReservedSlot() { public static Slot[] GetCustomSlots() => slots.Where(slot => slot.IsCustomSlot).ToArray(); internal static bool TryAddCustomSlot(string slotId, string ownerGuid, Func getName, Func itemIsValid, Func isActive) { + foreach (int index in ReservedIndices) + if (TryAddCustomSlotAt(index, slotId, ownerGuid, getName, itemIsValid, isActive)) + return true; + + EquipmentAndQuickSlots.LogWarning($"Could not add custom slot {slotId}: all {ReservedIndices.Length} custom slots are taken"); + return false; + } + + // Claims one specific reserved cell rather than the first free one. Index -> grid position + // is fixed math, so a consumer that has to land on a particular cell (a compatibility shim + // matching another mod's hardcoded grid coordinates) can ask for it by index. Returns false + // if that cell is already taken -- callers must cope, never assume. + internal static bool TryAddCustomSlotAt(int index, string slotId, string ownerGuid, Func getName, Func itemIsValid, Func isActive) { + if (Array.IndexOf(ReservedIndices, index) < 0) + return false; + string internalId = customSlotPrefix + slotId; if (slots.Any(slot => slot.ID == internalId)) return false; - foreach (int index in ReservedIndices) { - if (!slots[index].IsEmptySlot) - continue; - - slots[index] = new Slot(internalId, index, getName, itemIsValid, isActive) { OwnerGuid = ownerGuid }; - slots[index].UpdateGridPosition(); - ClearCachedItems(); - return true; - } + if (!slots[index].IsEmptySlot) + return false; - EquipmentAndQuickSlots.LogWarning($"Could not add custom slot {slotId}: all {ReservedIndices.Length} custom slots are taken"); - return false; + slots[index] = new Slot(internalId, index, getName, itemIsValid, isActive) { OwnerGuid = ownerGuid }; + slots[index].UpdateGridPosition(); + ClearCachedItems(); + return true; } internal static bool TryRemoveCustomSlot(string slotId) { @@ -649,6 +660,9 @@ internal static void UpdateSlotsGridPosition() { ClearCachedItems(); InventoryPatches.UpdatePlayerInventorySize(); + + // The slot region moved, so anything keyed to its grid coordinates has to follow. + BetterArcheryCompat.SyncQuiverRow(); } internal static void ClearCachedItems() => cachedItems.Clear(); diff --git a/EquipmentAndQuickSlots/src/ValConfig.cs b/EquipmentAndQuickSlots/src/ValConfig.cs index beb69c2f..7e6ab7fb 100644 --- a/EquipmentAndQuickSlots/src/ValConfig.cs +++ b/EquipmentAndQuickSlots/src/ValConfig.cs @@ -14,6 +14,7 @@ internal class ValConfig { public static ConfigEntry PreventStackAll; public static ConfigEntry PreventAutoPickup; public static ConfigEntry BackupEnabled; + public static ConfigEntry BetterArcheryQuiverIntegration; public static ConfigEntry EquipmentPanelPosition; public static ConfigEntry EquipmentPanelDraggable; public static ConfigEntry EquipmentPanelDragKey; @@ -109,6 +110,11 @@ private void CreateConfigValues(ConfigFile Config) { BackupEnabled = Config.Bind("Protections", "Slots backup enabled", true, new ConfigDescription("Automatically back up equipment and quick slot contents into the character save on every save, and restore them when the slots load empty (e.g. after the mod was temporarily removed).", null, new ConfigurationManagerAttributes { })); + // Read once at startup, before the slot cells are claimed and Better Archery's patches + // are removed, so a live change cannot leave the two mods half-integrated. + BetterArcheryQuiverIntegration = Config.Bind("Compatibility", "Better Archery quiver integration", true, + new ConfigDescription("Host Better Archery's quiver as three ammo slots in the equipment panel instead of letting it add its own inventory rows. Turn this off to disable Better Archery's quiver entirely; the quick slots hold ammo just as well. Takes effect on restart.", null, new ConfigurationManagerAttributes { })); + // Instantiate server synced config entries here EquipmentSlotsEnabled = BindServerConfig("Toggles", "Enable Equipment Slots", true, "Enable the equipment slots. Disabling this while items are equipped will attempt to move them to your inventory."); // A server push (or live edit) flips Slot.IsActive immediately; the validators must run diff --git a/ValheimUnity/AssetBundles/AssetBundles b/ValheimUnity/AssetBundles/AssetBundles index e212ecb7..113277d2 100644 Binary files a/ValheimUnity/AssetBundles/AssetBundles and b/ValheimUnity/AssetBundles/AssetBundles differ diff --git a/ValheimUnity/AssetBundles/epicloot b/ValheimUnity/AssetBundles/epicloot index e84641da..0eed2aca 100644 Binary files a/ValheimUnity/AssetBundles/epicloot and b/ValheimUnity/AssetBundles/epicloot differ diff --git a/ValheimUnity/Assets/EpicLoot/Prefabs/Enchanting/ConvertContent.prefab b/ValheimUnity/Assets/EpicLoot/Prefabs/Enchanting/ConvertContent.prefab index 262b979a..9705aa78 100644 --- a/ValheimUnity/Assets/EpicLoot/Prefabs/Enchanting/ConvertContent.prefab +++ b/ValheimUnity/Assets/EpicLoot/Prefabs/Enchanting/ConvertContent.prefab @@ -431,7 +431,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 20.2, y: -22.5} + m_AnchoredPosition: {x: 20.2, y: -20} m_SizeDelta: {x: 16, y: 16} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &3077070821824715350 @@ -948,6 +948,147 @@ MonoBehaviour: m_FlexibleWidth: -1 m_FlexibleHeight: -1 m_LayoutPriority: 1 +--- !u!1 &1859845221483170015 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4089206557727803802} + - component: {fileID: 4343186754464341680} + - component: {fileID: 5423348753740584606} + - component: {fileID: 1964688262220404374} + - component: {fileID: 8918646589472058129} + m_Layer: 5 + m_Name: ModeButton3 + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &4089206557727803802 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1859845221483170015} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 4333309631241344272} + - {fileID: 3378158984602147824} + m_Father: {fileID: 2721870475058442069} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 210, y: 40} + m_Pivot: {x: 0.5, y: 0} +--- !u!222 &4343186754464341680 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1859845221483170015} + m_CullTransparentMesh: 0 +--- !u!114 &5423348753740584606 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1859845221483170015} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: d1410c60c394cfa488c6bc1947ef3713, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &1964688262220404374 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1859845221483170015} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9085046f02f69544eb97fd06b6048fe2, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 0 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 2 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 21300000, guid: 3aebe3d8675b115478ffe8654dadfa71, + type: 3} + m_PressedSprite: {fileID: 21300000, guid: 9a64d05905dbd54478fc9cf4315dd013, type: 3} + m_SelectedSprite: {fileID: 21300000, guid: 3aebe3d8675b115478ffe8654dadfa71, type: 3} + m_DisabledSprite: {fileID: 21300000, guid: 38b0f074a50b52042aeb5c169e6c00ae, type: 3} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 5423348753740584606} + toggleTransition: 1 + graphic: {fileID: 4094566408539707903} + m_Group: {fileID: 3928853010674538173} + onValueChanged: + m_PersistentCalls: + m_Calls: [] + m_IsOn: 0 +--- !u!114 &8918646589472058129 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1859845221483170015} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 1777640247, guid: 2f9828d4dfcaa584280fbdd93cb3976a, type: 3} + m_Name: + m_EditorClassIdentifier: + DisabledColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} --- !u!1 &2019194573697281848 GameObject: m_ObjectHideFlags: 0 @@ -1262,7 +1403,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 20.2, y: -22.5} + m_AnchoredPosition: {x: 20.2, y: -20} m_SizeDelta: {x: 16, y: 16} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &3744533850319514306 @@ -1433,7 +1574,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 0, y: 1} - m_AnchoredPosition: {x: 20.2, y: -22.5} + m_AnchoredPosition: {x: 20.2, y: -20} m_SizeDelta: {x: 16, y: 16} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &6203761615563501849 @@ -1773,13 +1914,14 @@ RectTransform: - {fileID: 5253694638886926318} - {fileID: 9184999258981632456} - {fileID: 9107417599682013920} + - {fileID: 4089206557727803802} - {fileID: 5290788831447269387} m_Father: {fileID: 1123909639104041374} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 1} m_AnchorMax: {x: 0.5, y: 1} - m_AnchoredPosition: {x: 0, y: -67.67648} - m_SizeDelta: {x: 210, y: 155} + m_AnchoredPosition: {x: 0, y: -46} + m_SizeDelta: {x: 210, y: 178} m_Pivot: {x: 0.5, y: 1} --- !u!114 &4323609675437425779 MonoBehaviour: @@ -1799,7 +1941,7 @@ MonoBehaviour: m_Top: 0 m_Bottom: 0 m_ChildAlignment: 0 - m_Spacing: 10 + m_Spacing: 6 m_ChildForceExpandWidth: 0 m_ChildForceExpandHeight: 0 m_ChildControlWidth: 0 @@ -1911,6 +2053,7 @@ MonoBehaviour: - {fileID: 4508422266207416202} - {fileID: 2026437494872467033} - {fileID: 1971997444221504892} + - {fileID: 1964688262220404374} CostLabel: {fileID: 5941757644887029495} CostList: {fileID: 6756581182419673183} --- !u!82 &3945010393761910688 @@ -2192,7 +2335,7 @@ RectTransform: m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} m_AnchoredPosition: {x: 0, y: 0} - m_SizeDelta: {x: 210, y: 45} + m_SizeDelta: {x: 210, y: 40} m_Pivot: {x: 0.5, y: 0} --- !u!222 &8949963067271169121 CanvasRenderer: @@ -2614,6 +2757,101 @@ MonoBehaviour: m_FillOrigin: 0 m_UseSpriteMesh: 0 m_PixelsPerUnitMultiplier: 1 +--- !u!1 &4808124016383394469 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3378158984602147824} + - component: {fileID: 5561817767633531744} + - component: {fileID: 1877853485913571233} + - component: {fileID: 7015429213830036299} + m_Layer: 5 + m_Name: Text + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &3378158984602147824 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4808124016383394469} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 4089206557727803802} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 13.7503, y: -4.6349} + m_SizeDelta: {x: -44.8043, y: -9.2698} + m_Pivot: {x: 0.5, y: 1} +--- !u!222 &5561817767633531744 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4808124016383394469} + m_CullTransparentMesh: 0 +--- !u!114 &1877853485913571233 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4808124016383394469} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 0.631, b: 0.235, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_FontData: + m_Font: {fileID: 12800000, guid: f4c3114cc1af0ae41ac771c31de456be, type: 3} + m_FontSize: 8 + m_FontStyle: 0 + m_BestFit: 1 + m_MinSize: 1 + m_MaxSize: 14 + m_Alignment: 4 + m_AlignByGeometry: 0 + m_RichText: 1 + m_HorizontalOverflow: 0 + m_VerticalOverflow: 0 + m_LineSpacing: 1 + m_Text: $mod_epicloot_shardupgradedesc +--- !u!114 &7015429213830036299 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4808124016383394469} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: e19747de3f5aca642ab2be37e372fb86, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0, g: 0, b: 0, a: 1} + m_EffectDistance: {x: 1, y: 1} + m_UseGraphicAlpha: 1 --- !u!1 &5212234033646712330 GameObject: m_ObjectHideFlags: 0 @@ -3446,7 +3684,7 @@ RectTransform: m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} m_AnchoredPosition: {x: 0, y: 0} - m_SizeDelta: {x: 210, y: 45} + m_SizeDelta: {x: 210, y: 40} m_Pivot: {x: 0.5, y: 0} --- !u!222 &6256160619841899114 CanvasRenderer: @@ -4330,6 +4568,81 @@ MonoBehaviour: m_smoothFill: 0 m_smoothSpeed: 1 m_changeDelay: 0 +--- !u!1 &8304354856165678190 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7491673198936012164} + - component: {fileID: 8622966431200601151} + - component: {fileID: 4094566408539707903} + m_Layer: 5 + m_Name: Checkmark + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &7491673198936012164 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8304354856165678190} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 4333309631241344272} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: 1, y: 2} + m_SizeDelta: {x: 20, y: 20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &8622966431200601151 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8304354856165678190} + m_CullTransparentMesh: 1 +--- !u!114 &4094566408539707903 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8304354856165678190} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.04401779, g: 1, b: 0, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: a162df59608dd3a43affce5732e462d2, type: 3} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 --- !u!1 &8332376062543468986 GameObject: m_ObjectHideFlags: 0 @@ -4370,7 +4683,7 @@ RectTransform: m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 0, y: 0} m_AnchoredPosition: {x: 0, y: 0} - m_SizeDelta: {x: 210, y: 45} + m_SizeDelta: {x: 210, y: 40} m_Pivot: {x: 0.5, y: 0} --- !u!222 &3648130287696879116 CanvasRenderer: @@ -4486,6 +4799,82 @@ MonoBehaviour: m_EditorClassIdentifier: Audio: {fileID: 3945010393761910688} SFX: {fileID: 8300000, guid: 2172398bc32a1e741977becabd38ce2e, type: 3} +--- !u!1 &8516522053557815705 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4333309631241344272} + - component: {fileID: 7473391500308404311} + - component: {fileID: 8059280774586096148} + m_Layer: 5 + m_Name: Background + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &4333309631241344272 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8516522053557815705} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 7491673198936012164} + m_Father: {fileID: 4089206557727803802} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 20.2, y: -20} + m_SizeDelta: {x: 16, y: 16} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &7473391500308404311 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8516522053557815705} + m_CullTransparentMesh: 1 +--- !u!114 &8059280774586096148 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8516522053557815705} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0, g: 0, b: 0, a: 0.5019608} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: c8e11af65b9d8264d8436c1d3e45d175, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 --- !u!1 &8532601501579058258 GameObject: m_ObjectHideFlags: 0