diff --git a/pom.xml b/pom.xml index e6296fc..b4a469b 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,23 @@ + + + com.mojang + authlib + 7.0.61 + test + + ** + + + + + local + item-nbt-api-plugin + 2.16.1-tfmc-c571502e7686 + provided + beer.devs @@ -99,6 +116,10 @@ + + minecraft-libraries + https://libraries.minecraft.net/ + papermc https://repo.papermc.io/repository/maven-public/ diff --git a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java index 189c7f5..098abf2 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/cache/Cache.java @@ -39,6 +39,11 @@ public class Cache { public static boolean debugStatRefresh; + /** Crafted pieces still wearing an old default look get their recipe's current model. */ + public static boolean armourLookMigration; + /** Old default looks per recipe id, for recipes whose old look did not come from their metal's type model. */ + public static Map> legacyModels = new HashMap<>(); + public static boolean showIngredientStats = true; /** Divisors applied to bucket-averaged stats before template factors (e.g. movement_speed: 100). */ diff --git a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java index 1ff236d..fdfe206 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/loaders/ConfigLoader.java @@ -79,6 +79,19 @@ public void load(File configFile) { Cache.hitOvershootWarnMessage = overshootMessage; Cache.debugStatRefresh = config.getBoolean("debug-stat-refresh", false); + + Cache.armourLookMigration = config.getBoolean("armour-look-migration.enabled", false); + Cache.legacyModels.clear(); + for (String entry : config.getStringList("armour-look-migration.legacy-models")) { + int open = entry.indexOf('('); + if (open <= 0 || !entry.endsWith(")")) { + AdvancedCrafting.plugin.getLogger().warning("Ignoring armour-look-migration legacy model '" + entry + + "'; expected ()"); + continue; + } + Cache.legacyModels.computeIfAbsent(entry.substring(0, open).trim().toLowerCase(), key -> new ArrayList<>()) + .add(entry.substring(open + 1, entry.length() - 1).trim()); + } Cache.showIngredientStats = config.getBoolean("show-ingredient-stats", true); Cache.globalStatOffsets.clear(); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java index 3f7cc1f..fa8c2a1 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/managers/CraftRefreshListener.java @@ -126,7 +126,14 @@ private void tryRefresh(ItemStack item, ItemConsumer writer) { if (item == null || item.getType().isAir() || !AcItemRefresher.isManaged(item)) { return; } - ItemStack refreshed = AcItemRefresher.refreshIfOutdated(item); + ItemStack refreshed; + try { + refreshed = AcItemRefresher.refreshIfOutdated(item); + } catch (RuntimeException e) { + // One broken item (e.g. a recorded craft whose MMOItems data is gone) must not stop the rest of a sweep. + AdvancedCrafting.plugin.getLogger().warning("Could not refresh " + item.getType() + ": " + e); + return; + } if (refreshed == item) { return; } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java index c7ad042..3764e7a 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/crafting/CraftingStation.java @@ -1,23 +1,19 @@ package net.tfminecraft.advancedcrafting.objects.crafting; -import net.tfminecraft.advancedcrafting.util.LegacyModelData; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.WordUtils; import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.command.ConsoleCommandSender; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; import io.lumine.mythic.lib.api.item.NBTItem; import net.tfminecraft.tlibs.TLibs; @@ -53,6 +49,7 @@ import net.tfminecraft.advancedcrafting.utils.CraftTierLore; import net.tfminecraft.advancedcrafting.utils.MMOStatApplicator; import net.tfminecraft.advancedcrafting.utils.MajorityTierResolver; +import net.tfminecraft.advancedcrafting.utils.ModelApplier; import net.tfminecraft.advancedcrafting.utils.ModelSchemeResolver; import net.tfminecraft.advancedcrafting.utils.ProfessionPermissions; import net.tfminecraft.advancedcrafting.lifecycle.CraftLifecycle; @@ -527,29 +524,13 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea return StationFeedback.SUCCESS; } - // This path mutates the existing ItemStack; replacing it would change aliases held by callers. - @SuppressWarnings("deprecation") private ItemStack applyModel(ItemStack i, ModelScheme scheme) { - // A model named after the recipe (e.g. heavy_helmet) wins over the shared type (helmet). - String path = scheme.getModel(recipe.getId()); - if(path == null) { - path = scheme.getModel(recipe.getType()); - } + String path = ModelApplier.modelFor(scheme, recipe); if(path == null) { Bukkit.getLogger().warning("AC: No model in the scheme "+scheme.getId()+" for the recipe type "+recipe.getType()); return i; } - String type = path.split("\\.")[0]; - if(type.equalsIgnoreCase("v")) { - i.setType(Material.valueOf(path.split("\\.")[1].toUpperCase())); - ItemMeta m = i.getItemMeta(); - LegacyModelData.set(m, Integer.parseInt(path.split("\\.")[2])); - i.setItemMeta(m); - } else if(type.equalsIgnoreCase("ia")) { - ItemAPI api = TLibs.getItemAPI(); - i = api.getArmorMerger().merge(i, Optional.empty(), path); - } - return i; + return ModelApplier.apply(i, path); } public StationFeedback hit(Player p, ItemStack i) { diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemRefresher.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemRefresher.java index 95bdef9..d143cb4 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemRefresher.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/AcItemRefresher.java @@ -32,12 +32,12 @@ public static ItemStack refreshIfOutdated(ItemStack item) { } CraftStatRefresher.RefreshResult crafted = CraftStatRefresher.refreshIfOutdated(item); if (crafted.isChanged()) { - return crafted.getItem(); + return ArmourLookMigrator.migrate(crafted.getItem()); } AcItemLoreRefresher.RefreshResult lore = AcItemLoreRefresher.refreshIfOutdated(item); if (lore.isChanged()) { - return lore.getItem(); + return ArmourLookMigrator.migrate(lore.getItem()); } - return item; + return ArmourLookMigrator.migrate(item); } } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java new file mode 100644 index 0000000..0b85229 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java @@ -0,0 +1,136 @@ +package net.tfminecraft.advancedcrafting.utils; + +import java.util.List; +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import io.lumine.mythic.lib.api.item.NBTItem; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.RecipeLoader; +import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe; +import net.tfminecraft.advancedcrafting.objects.data.CraftProvenance; +import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; + +/** + * Brings crafted pieces made before the per-recipe models up to date. A piece that still wears the old default + * look of its recipe (its metal's type model, or a configured legacy model) gets the recipe's current model + * through the same merge skins use, so its MMOItems data stays. Any other look is a skin and is kept. Every + * recorded craft also gets its model scheme tag, which ArmourShop's metal skin lines read, and pieces with an + * ItemsAdder look are opted out of ItemsAdder's auto_update (see {@link IaAutoUpdate}). + */ +public final class ArmourLookMigrator { + private ArmourLookMigrator() { + } + + /** The same item when nothing changes, otherwise an updated copy. */ + public static ItemStack migrate(ItemStack item) { + if (!Cache.armourLookMigration || item == null || item.getType().isAir() || !item.hasItemMeta()) { + return item; + } + CraftProvenance provenance = CraftProvenance.readFrom(item); + if (provenance == null) { + return item; + } + CraftingRecipe recipe = RecipeLoader.getByString(provenance.getRecipeId()); + ModelScheme scheme = ModelSchemeResolver.resolve(recipe, provenance.getInputs()); + if (scheme == null) { + return item; + } + ItemStack result = item; + String target = scheme.getModel(recipe.getId()); + if (target != null && !wears(item, target) && wearsLegacyLook(item, recipe, scheme)) { + String before = describe(item); + result = ModelApplier.apply(item.clone(), target); + ItemMeta meta = result.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.previousModel(), PersistentDataType.STRING, before); + result.setItemMeta(meta); + Bukkit.getLogger().info("[AC][LookMigration] " + recipe.getId() + " (" + scheme.getId() + "): " + before + + " -> " + target); + } + PersistentDataContainer tags = result.getItemMeta().getPersistentDataContainer(); + if (!scheme.getId().equals(tags.get(PDCKeys.craftModelScheme(), PersistentDataType.STRING))) { + if (result == item) { + result = item.clone(); + } + ItemMeta meta = result.getItemMeta(); + meta.getPersistentDataContainer().set(PDCKeys.craftModelScheme(), PersistentDataType.STRING, scheme.getId()); + result.setItemMeta(meta); + } + if (IaAutoUpdate.isExposed(result)) { + if (result == item) { + result = item.clone(); + } + IaAutoUpdate.protect(result); + } + return result; + } + + /** Configured legacy models replace the scheme's type model, e.g. infantry pieces wore the paper look. */ + static boolean wearsLegacyLook(ItemStack item, CraftingRecipe recipe, ModelScheme scheme) { + List legacy = Cache.legacyModels.get(recipe.getId().toLowerCase(Locale.ROOT)); + if (legacy != null) { + for (String path : legacy) { + if (wears(item, path)) { + return true; + } + } + return false; + } + String typeModel = scheme.getModel(recipe.getType()); + return typeModel != null && wears(item, typeModel); + } + + /** Whether the item shows this scheme model: the same ItemsAdder item, or the same vanilla material and model. */ + static boolean wears(ItemStack item, String path) { + String[] parts = path.split("\\."); + String ia = iaId(item); + if (parts[0].equalsIgnoreCase("ia")) { + return ia != null && ia.equalsIgnoreCase(path.substring(3)); + } + if (!parts[0].equalsIgnoreCase("v") || parts.length != 3 || ia != null || hasItemsAdderCompound(item) + || !item.getType().name().equalsIgnoreCase(parts[1])) { + return false; + } + int model; + try { + model = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + return false; + } + ItemMeta meta = item.getItemMeta(); + int current = LegacyModelData.has(meta) ? LegacyModelData.get(meta) : 0; + return current == model; + } + + /** The look as a scheme path, for the log and the previous-model tag. */ + static String describe(ItemStack item) { + String ia = iaId(item); + if (ia != null) { + return "ia." + ia; + } + ItemMeta meta = item.getItemMeta(); + int model = LegacyModelData.has(meta) ? LegacyModelData.get(meta) : 0; + return "v." + item.getType().name().toLowerCase(Locale.ROOT) + "." + model; + } + + /** TLibs stamps merged ItemsAdder looks as {@code ia: .}; returned as namespace:id. */ + private static String iaId(ItemStack item) { + NBTItem nbt = NBTItem.get(item); + if (!nbt.hasTag("ia")) { + return null; + } + String ia = nbt.getString("ia"); + int dot = ia.indexOf('.'); + return dot > 0 && dot < ia.length() - 1 ? ia.substring(0, dot) + ":" + ia.substring(dot + 1) : null; + } + + private static boolean hasItemsAdderCompound(ItemStack item) { + return NBTItem.get(item).hasTag("itemsadder"); + } +} diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatRefresher.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatRefresher.java index 7074870..cbf502a 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatRefresher.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/CraftStatRefresher.java @@ -46,10 +46,12 @@ public static RefreshResult refresh(ItemStack item, boolean force) { StatData newStats = CraftStatCalculator.compute(recipe, provenance.getInputs()); StatRefreshDebug.logBefore(item, provenance, recipe, newStats); - LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(item)); + NBTItem nbt = NBTItem.get(item); + LiveMMOItem mmo = new LiveMMOItem(nbt); MMOStatApplicator.applyExternalLayer(mmo, newStats, CraftStatCalculator.collectManagedStatIds(recipe), true); + RefreshKeeper.keepWear(nbt, mmo); - ItemStack rebuilt = mmo.newBuilder().build(); + ItemStack rebuilt = RefreshKeeper.keepAppearance(item, mmo.newBuilder().build()); rebuilt.setAmount(item.getAmount()); provenance.syncRevisions(); provenance.applyTo(rebuilt); diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/IaAutoUpdate.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/IaAutoUpdate.java new file mode 100644 index 0000000..c9591c0 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/IaAutoUpdate.java @@ -0,0 +1,38 @@ +package net.tfminecraft.advancedcrafting.utils; + +import org.bukkit.inventory.ItemStack; + +import de.tr7zw.nbtapi.NBT; +import de.tr7zw.nbtapi.iface.ReadableNBT; + +/** + * ItemsAdder's auto_update rebuilds an item from its definition when that definition changes (or a player's + * slot hashes no longer match), keeping only amount, durability, persistent data, trim, name and enchantments. + * On a crafted MMOItems piece wearing an ItemsAdder look that wipes its stats, gems and lore (the Forj + * incident). ItemsAdder skips items whose {@code itemsadder} compound has {@code override_auto_update: true}. + */ +public final class IaAutoUpdate { + private static final String COMPOUND = "itemsadder"; + private static final String OVERRIDE = "override_auto_update"; + + private IaAutoUpdate() { + } + + /** Whether the item wears an ItemsAdder look that ItemsAdder may still rebuild. */ + public static boolean isExposed(ItemStack item) { + return NBT.get(item, nbt -> { + if (!nbt.hasTag(COMPOUND)) { + return false; + } + ReadableNBT compound = nbt.getCompound(COMPOUND); + return compound != null && !Boolean.TRUE.equals(compound.getBoolean(OVERRIDE)); + }); + } + + /** Opts the item out of ItemsAdder's auto_update. */ + public static void protect(ItemStack item) { + NBT.modify(item, nbt -> { + nbt.getOrCreateCompound(COMPOUND).setBoolean(OVERRIDE, true); + }); + } +} diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/ModelApplier.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/ModelApplier.java new file mode 100644 index 0000000..98e6d44 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/ModelApplier.java @@ -0,0 +1,39 @@ +package net.tfminecraft.advancedcrafting.utils; + +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe; +import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import net.tfminecraft.tlibs.TLibs; + +/** Model scheme paths: {@code v..} for vanilla items, {@code ia.:} for ItemsAdder. */ +public final class ModelApplier { + private ModelApplier() { + } + + /** A model named after the recipe (e.g. heavy_helmet) wins over the shared type (helmet). */ + public static String modelFor(ModelScheme scheme, CraftingRecipe recipe) { + String path = scheme.getModel(recipe.getId()); + return path != null ? path : scheme.getModel(recipe.getType()); + } + + // This path mutates the existing ItemStack; replacing it would change aliases held by callers. + @SuppressWarnings("deprecation") + public static ItemStack apply(ItemStack i, String path) { + String type = path.split("\\.")[0]; + if(type.equalsIgnoreCase("v")) { + i.setType(Material.valueOf(path.split("\\.")[1].toUpperCase())); + ItemMeta m = i.getItemMeta(); + LegacyModelData.set(m, Integer.parseInt(path.split("\\.")[2])); + i.setItemMeta(m); + } else if(type.equalsIgnoreCase("ia")) { + i = TLibs.getItemAPI().getArmorMerger().merge(i, Optional.empty(), path); + } + return i; + } +} diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java index 7ca4c77..672f9bb 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java @@ -29,6 +29,11 @@ public static NamespacedKey craftModelScheme() { return new NamespacedKey(AdvancedCrafting.plugin, "ac_craft_model_scheme"); } + /** The old default look a crafted piece wore before the look migration replaced it. */ + public static NamespacedKey previousModel() { + return new NamespacedKey(AdvancedCrafting.plugin, "ac_previous_model"); + } + public static NamespacedKey scrapBase() { return new NamespacedKey(AdvancedCrafting.plugin, "ac_scrap_base"); } diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java new file mode 100644 index 0000000..50cdcda --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java @@ -0,0 +1,68 @@ +package net.tfminecraft.advancedcrafting.utils; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.LeatherArmorMeta; + +import io.lumine.mythic.lib.api.item.NBTItem; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.Indyuce.mmoitems.stat.data.DoubleData; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver; + +/** + * A stat refresh rebuilds the item from its MMOItems data, which only covers stats. This puts back what the + * player's item had besides those: its wear, skin or model, display name (ArmourShop names) and other + * plugins' data such as gem socket rarities. + */ +public final class RefreshKeeper { + private static final String DURABILITY = "MMOITEMS_DURABILITY"; + private static final String MAX_DURABILITY = "MMOITEMS_MAX_DURABILITY"; + + private RefreshKeeper() { + } + + /** + * Before the rebuild: keep the damage the item has taken, so a refresh neither repairs nor breaks it. + * MMOItems builds a full item unless its current durability is set. + */ + public static void keepWear(NBTItem old, MMOItem mmo) { + if (!old.hasTag(DURABILITY) || !old.hasTag(MAX_DURABILITY) || !mmo.hasData(ItemStats.MAX_DURABILITY)) { + return; + } + int current = old.getInteger(DURABILITY); + int taken = old.getInteger(MAX_DURABILITY) - current; + int max = (int) ((DoubleData) mmo.getData(ItemStats.MAX_DURABILITY)).getValue(); + int kept = Math.min(max, Math.max(current > 0 ? 1 : 0, max - taken)); + mmo.setData(ItemStats.CUSTOM_DURABILITY, new DoubleData(kept)); + } + + /** After the rebuild: the old look, name and foreign data on the rebuilt item. */ + public static ItemStack keepAppearance(ItemStack old, ItemStack rebuilt) { + ItemStack kept = ItemSkinPreserver.apply(old, rebuilt); + ItemMeta oldMeta = old.getItemMeta(); + if (kept.getType() != old.getType()) { + kept.setType(old.getType()); + } + ItemMeta meta = kept.getItemMeta(); + if (oldMeta == null || meta == null) { + return kept; + } + if (LegacyModelData.has(oldMeta)) { + LegacyModelData.set(meta, LegacyModelData.get(oldMeta)); + } + if (oldMeta instanceof LeatherArmorMeta oldLeather && meta instanceof LeatherArmorMeta leather) { + leather.setColor(oldLeather.getColor()); + } + if (oldMeta.hasDisplayName()) { + meta.displayName(oldMeta.displayName()); + } + oldMeta.getPersistentDataContainer().copyTo(meta.getPersistentDataContainer(), false); + kept.setItemMeta(meta); + if (IaAutoUpdate.isExposed(kept)) { + IaAutoUpdate.protect(kept); + } + return kept; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 825057a..1fe01fb 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -51,6 +51,23 @@ show-ingredient-stats: true # Log stat template / ingredient revision refreshes to console ([AC][StatRefresh]). debug-stat-refresh: false +# Crafted pieces still wearing their old default look get their recipe's current model (e.g. light_helmet) +# when they are refreshed: on join, in opened chests, on click and on equip. The merge keeps all MMOItems +# data; skinned pieces keep their skin. Every recorded craft also gets its model scheme (metal) tag. +# The old look is the scheme's type model (helmet, ...), unless legacy-models lists the recipe: +# (), for looks that came from another ingredient (infantry paper, mage dust). +armour-look-migration: + enabled: false + legacy-models: + - infantry_helmet(v.carved_pumpkin.1) + - infantry_chestplate(v.golden_chestplate.0) + - infantry_leggings(v.golden_leggings.0) + - infantry_boots(v.golden_boots.0) + - mage_helmet(ia.tfmc_armor:iron_mage_helmet) + - mage_chestplate(ia.tfmc_armor:iron_mage_chestplate) + - mage_leggings(ia.tfmc_armor:iron_mage_leggings) + - mage_boots(ia.tfmc_armor:iron_mage_boots) + # Divide bucket-averaged stat values before template factors (movement_speed config uses ~100x larger numbers than MMOItems). global-stat-offsets: movement_speed: 100 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 8bcfe79..0f7cefa 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,6 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs, MMOCore, MMOItems, MythicLib] +softdepend: [NBTAPI] commands: ac: diff --git a/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java new file mode 100644 index 0000000..e51c295 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java @@ -0,0 +1,371 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.nio.file.Files; +import java.util.*; +import net.tfminecraft.advancedcrafting.cache.Cache; +import net.tfminecraft.advancedcrafting.loaders.*; +import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe; +import net.tfminecraft.advancedcrafting.objects.data.*; +import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import net.tfminecraft.advancedcrafting.utils.*; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockito.MockedStatic; + +class ArmourLookMigratorTest extends CoverageSupport { + /** Stands in for TLibs' {@code ia} NBT tag ("namespace.id"). */ + static final NamespacedKey IA = new NamespacedKey("test", "ia"); + /** Stands in for the ItemsAdder {@code itemsadder} compound. */ + static final NamespacedKey COMPOUND = new NamespacedKey("test", "itemsadder"); + /** Stands in for the custom model data component, which MockBukkit does not implement. */ + static final NamespacedKey MODEL = new NamespacedKey("test", "model"); + /** Stands in for itemsadder.override_auto_update. */ + static final NamespacedKey PROTECTED = new NamespacedKey("test", "protected"); + + MockedStatic nbts; + MockedStatic models; + MockedStatic autoUpdate; + CraftingRecipe light; + CraftingRecipe infantry; + + @BeforeEach + void world() throws Exception { + autoUpdate = mockStatic(IaAutoUpdate.class); + autoUpdate + .when(() -> IaAutoUpdate.isExposed(any(ItemStack.class))) + .thenAnswer( + call -> { + var pdc = ((ItemStack) call.getArgument(0)).getItemMeta().getPersistentDataContainer(); + return (pdc.has(IA) || pdc.has(COMPOUND)) && !pdc.has(PROTECTED); + }); + autoUpdate + .when(() -> IaAutoUpdate.protect(any(ItemStack.class))) + .thenAnswer( + call -> { + ItemStack item = call.getArgument(0); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(PROTECTED, PersistentDataType.BYTE, (byte) 1); + item.setItemMeta(meta); + return null; + }); + models = mockStatic(LegacyModelData.class); + models + .when(() -> LegacyModelData.set(any(ItemMeta.class), any())) + .thenAnswer( + call -> { + ItemMeta meta = call.getArgument(0); + Integer value = call.getArgument(1); + if (value == null) meta.getPersistentDataContainer().remove(MODEL); + else meta.getPersistentDataContainer().set(MODEL, PersistentDataType.INTEGER, value); + return null; + }); + models + .when(() -> LegacyModelData.has(any(ItemMeta.class))) + .thenAnswer(call -> ((ItemMeta) call.getArgument(0)).getPersistentDataContainer().has(MODEL)); + models + .when(() -> LegacyModelData.get(any(ItemMeta.class))) + .thenAnswer( + call -> + ((ItemMeta) call.getArgument(0)) + .getPersistentDataContainer() + .get(MODEL, PersistentDataType.INTEGER)); + nbts = mockStatic(NBTItem.class); + nbts.when(() -> NBTItem.get(any(ItemStack.class))) + .thenAnswer( + call -> { + ItemStack item = call.getArgument(0); + var pdc = item.getItemMeta().getPersistentDataContainer(); + String ia = pdc.get(IA, PersistentDataType.STRING); + var nbt = mock(NBTItem.class); + when(nbt.hasTag("ia")).thenReturn(ia != null); + when(nbt.getString("ia")).thenReturn(ia); + when(nbt.hasTag("itemsadder")).thenReturn(ia != null || pdc.has(COMPOUND)); + return nbt; + }); + // The merge swaps the look and stamps the ItemsAdder id, like ArmorMerger. + when(items.getArmorMerger().merge(any(ItemStack.class), any(), anyString())) + .thenAnswer( + call -> { + ItemStack item = call.getArgument(0); + String id = ((String) call.getArgument(2)).substring(3); + item.setType(Material.LEATHER_HELMET); + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(IA, PersistentDataType.STRING, id.replace(':', '.')); + item.setItemMeta(meta); + return item; + }); + SchemeLoader.models.put( + "iron", + new ModelScheme( + "iron", + yaml( + "models: ['helmet(v.chainmail_helmet.0)', 'light_helmet(ia.tfmc_armor:iron_light_helmet)'," + + " 'infantry_helmet(ia.tfmc_armor:base_metal_iron_infantry_helmet)']"))); + SchemeLoader.models.put( + "bronze", + new ModelScheme( + "bronze", + yaml( + "models: ['helmet(ia.tfmc_armor:bronze_helmet)'," + + " 'light_helmet(ia.tfmc_armor:bronze_light_helmet)']"))); + SchemeLoader.models.put("plain", new ModelScheme("plain", yaml("models: []"))); + ingredient("iron", "model-scheme: iron"); + ingredient("bronze", "model-scheme: bronze"); + ingredient("plain", "model-scheme: plain"); + light = armour("light_helmet"); + infantry = armour("infantry_helmet"); + Cache.armourLookMigration = true; + Cache.legacyModels.clear(); + Cache.legacyModels.put("infantry_helmet", List.of("v.carved_pumpkin.1")); + } + + @AfterEach + void closeNbt() { + nbts.close(); + models.close(); + autoUpdate.close(); + } + + CraftingRecipe armour(String id) throws Exception { + var recipe = + new CraftingRecipe( + id, + yaml( + "name: '%material% Helmet'\ncategory: weapons\ntemplate: HELMETS.TEST\ntype: helmet\n" + + "recipe: ['metal.4']")); + RecipeLoader.map.put(id, recipe); + return recipe; + } + + /** A piece crafted from {@code metal} that wears a vanilla look. */ + ItemStack crafted(String recipe, String metal, Material material, Integer model) { + var item = new ItemStack(material); + var meta = item.getItemMeta(); + LegacyModelData.set(meta, model); + item.setItemMeta(meta); + new CraftProvenance(recipe, "fine", List.of(new CraftInput("ingredient", metal, 4, 0)), 0) + .applyTo(item); + // Older crafts carry no scheme tag. + meta = item.getItemMeta(); + meta.getPersistentDataContainer().remove(PDCKeys.craftModelScheme()); + item.setItemMeta(meta); + return item; + } + + static ItemStack withIa(ItemStack item, String ia) { + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(IA, PersistentDataType.STRING, ia); + item.setItemMeta(meta); + return item; + } + + static String tag(ItemStack item, NamespacedKey key) { + return item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); + } + + static String ia(ItemStack item) { + return tag(item, IA); + } + + @Test + void oldDefaultLooksTakeTheRecipeModelAndKeepARecordOfTheOldOne() { + var old = crafted("light_helmet", "iron", Material.CHAINMAIL_HELMET, null); + var migrated = ArmourLookMigrator.migrate(old); + + assertNotSame(old, migrated); + assertEquals(Material.CHAINMAIL_HELMET, old.getType(), "the original stack is left alone"); + assertNull(tag(old, PDCKeys.craftModelScheme())); + assertEquals("tfmc_armor.iron_light_helmet", ia(migrated)); + assertEquals("v.chainmail_helmet.0", tag(migrated, PDCKeys.previousModel())); + assertEquals("iron", tag(migrated, PDCKeys.craftModelScheme())); + assertEquals("light_helmet", CraftProvenance.readFrom(migrated).getRecipeId()); + assertTrue(migrated.getItemMeta().getPersistentDataContainer().has(PROTECTED)); + + // Migrated pieces are left alone from then on. + assertSame(migrated, ArmourLookMigrator.migrate(migrated)); + + // An ItemsAdder type model counts as the old look too. + var bronze = withIa(crafted("light_helmet", "bronze", Material.LEATHER_HELMET, 5), "tfmc_armor.bronze_helmet"); + var newBronze = ArmourLookMigrator.migrate(bronze); + assertEquals("tfmc_armor.bronze_light_helmet", ia(newBronze)); + assertEquals("ia.tfmc_armor:bronze_helmet", tag(newBronze, PDCKeys.previousModel())); + assertEquals("bronze", tag(newBronze, PDCKeys.craftModelScheme())); + } + + @Test + void skinsAndOtherLooksStayButStillGetTheMetalTag() { + var skinned = + withIa( + crafted("light_helmet", "iron", Material.LEATHER_HELMET, 10194), + "tfmc_submissions.geofflive_thalendorian_armor_iron_helmet"); + var tagged = ArmourLookMigrator.migrate(skinned); + assertNotSame(skinned, tagged); + assertEquals("tfmc_submissions.geofflive_thalendorian_armor_iron_helmet", ia(tagged)); + assertEquals(Material.LEATHER_HELMET, tagged.getType()); + assertNull(tag(tagged, PDCKeys.previousModel())); + assertEquals("iron", tag(tagged, PDCKeys.craftModelScheme())); + assertTrue(tagged.getItemMeta().getPersistentDataContainer().has(PROTECTED)); + assertSame(tagged, ArmourLookMigrator.migrate(tagged)); + // A tagged skin that ItemsAdder could still rebuild is protected on its own. + var exposed = tagged.clone(); + var exposedMeta = exposed.getItemMeta(); + exposedMeta.getPersistentDataContainer().remove(PROTECTED); + exposed.setItemMeta(exposedMeta); + var protectedCopy = ArmourLookMigrator.migrate(exposed); + assertNotSame(exposed, protectedCopy); + assertTrue(protectedCopy.getItemMeta().getPersistentDataContainer().has(PROTECTED)); + verify(items.getArmorMerger(), never()).merge(any(ItemStack.class), any(), anyString()); + + // A vanilla skin of another model, a vanilla material carrying an ItemsAdder compound, and a + // stale ia tag on the old material all stay as they are. + var otherModel = ArmourLookMigrator.migrate(crafted("light_helmet", "iron", Material.CHAINMAIL_HELMET, 3)); + assertEquals(Material.CHAINMAIL_HELMET, otherModel.getType()); + assertFalse(otherModel.getItemMeta().getPersistentDataContainer().has(PROTECTED)); + var compoundItem = crafted("light_helmet", "iron", Material.CHAINMAIL_HELMET, null); + var meta = compoundItem.getItemMeta(); + meta.getPersistentDataContainer().set(COMPOUND, PersistentDataType.STRING, "x"); + compoundItem.setItemMeta(meta); + assertEquals(Material.CHAINMAIL_HELMET, ArmourLookMigrator.migrate(compoundItem).getType()); + var stale = withIa(crafted("light_helmet", "iron", Material.CHAINMAIL_HELMET, null), "old.skin"); + assertEquals("old.skin", ia(ArmourLookMigrator.migrate(stale))); + var otherMaterial = ArmourLookMigrator.migrate(crafted("light_helmet", "iron", Material.IRON_HELMET, null)); + assertEquals(Material.IRON_HELMET, otherMaterial.getType()); + } + + @Test + void configuredLegacyModelsReplaceTheMetalTypeModel() { + var paperLook = crafted("infantry_helmet", "iron", Material.CARVED_PUMPKIN, 1); + var migrated = ArmourLookMigrator.migrate(paperLook); + assertEquals("tfmc_armor.base_metal_iron_infantry_helmet", ia(migrated)); + assertEquals("v.carved_pumpkin.1", tag(migrated, PDCKeys.previousModel())); + + // An infantry piece in plain chainmail was skinned that way; the metal's type model does not apply. + var chainmail = ArmourLookMigrator.migrate(crafted("infantry_helmet", "iron", Material.CHAINMAIL_HELMET, null)); + assertEquals(Material.CHAINMAIL_HELMET, chainmail.getType()); + assertEquals("iron", tag(chainmail, PDCKeys.craftModelScheme())); + } + + @Test + void piecesWithoutARecipeModelOrARecordAreOnlyTaggedOrSkipped() throws Exception { + // A scheme with no model for the recipe (weapons, other materials) only gets the tag. + var plain = crafted("light_helmet", "plain", Material.CHAINMAIL_HELMET, null); + var tagged = ArmourLookMigrator.migrate(plain); + assertEquals(Material.CHAINMAIL_HELMET, tagged.getType()); + assertEquals("plain", tag(tagged, PDCKeys.craftModelScheme())); + // The plain scheme has no type model either. + armour("plain_cap"); + assertFalse( + invokeLegacy(crafted("plain_cap", "plain", Material.CHAINMAIL_HELMET, null), "plain_cap", "plain")); + + // Unknown recipes and inputs resolve no scheme; items without a record are not crafted pieces. + var unknownRecipe = crafted("gone", "iron", Material.CHAINMAIL_HELMET, null); + assertSame(unknownRecipe, ArmourLookMigrator.migrate(unknownRecipe)); + var unknownMetal = crafted("light_helmet", "gone", Material.CHAINMAIL_HELMET, null); + assertSame(unknownMetal, ArmourLookMigrator.migrate(unknownMetal)); + var premade = new ItemStack(Material.CHAINMAIL_HELMET); + assertSame(premade, ArmourLookMigrator.migrate(premade)); + assertNull(ArmourLookMigrator.migrate(null)); + var air = new ItemStack(Material.AIR); + assertSame(air, ArmourLookMigrator.migrate(air)); + var noMeta = mock(ItemStack.class); + when(noMeta.getType()).thenReturn(Material.CHAINMAIL_HELMET); + assertSame(noMeta, ArmourLookMigrator.migrate(noMeta)); + + // Switched off, nothing changes. + Cache.armourLookMigration = false; + var old = crafted("light_helmet", "iron", Material.CHAINMAIL_HELMET, null); + assertSame(old, ArmourLookMigrator.migrate(old)); + } + + boolean invokeLegacy(ItemStack item, String recipe, String scheme) throws Exception { + var m = + ArmourLookMigrator.class.getDeclaredMethod( + "wearsLegacyLook", ItemStack.class, CraftingRecipe.class, ModelScheme.class); + m.setAccessible(true); + return (boolean) m.invoke(null, item, RecipeLoader.map.get(recipe), SchemeLoader.models.get(scheme)); + } + + @Test + void looksAreMatchedAndDescribedByTheirSchemePath() throws Exception { + var wears = + ArmourLookMigrator.class.getDeclaredMethod("wears", ItemStack.class, String.class); + wears.setAccessible(true); + var describe = ArmourLookMigrator.class.getDeclaredMethod("describe", ItemStack.class); + describe.setAccessible(true); + var plain = new ItemStack(Material.CHAINMAIL_HELMET); + + assertTrue((boolean) wears.invoke(null, plain, "v.chainmail_helmet.0")); + assertFalse((boolean) wears.invoke(null, plain, "v.chainmail_helmet")); + assertFalse((boolean) wears.invoke(null, plain, "v.chainmail_helmet.x"), "a malformed model never matches"); + assertFalse((boolean) wears.invoke(null, plain, "m.helmets.light_custom_helmet")); + assertFalse((boolean) wears.invoke(null, plain, "ia.tfmc_armor:iron_light_helmet")); + assertEquals("v.chainmail_helmet.0", describe.invoke(null, plain)); + + var modelled = new ItemStack(Material.CARVED_PUMPKIN); + var meta = modelled.getItemMeta(); + LegacyModelData.set(meta, 1); + modelled.setItemMeta(meta); + assertEquals("v.carved_pumpkin.1", describe.invoke(null, modelled)); + + var skinned = withIa(new ItemStack(Material.LEATHER_HELMET), "tfmc_armor.iron_light_helmet"); + assertTrue((boolean) wears.invoke(null, skinned, "ia.TFMC_ARMOR:iron_light_helmet")); + assertEquals("ia.tfmc_armor:iron_light_helmet", describe.invoke(null, skinned)); + + // Malformed ia tags read as no ItemsAdder id. + for (String broken : List.of("nodot", ".id", "ns.")) { + var item = withIa(new ItemStack(Material.LEATHER_HELMET), broken); + assertEquals("v.leather_helmet.0", describe.invoke(null, item)); + } + } + + @Test + void modelPathsApplyVanillaAndItemsAdderLooks() throws Exception { + var scheme = SchemeLoader.models.get("iron"); + assertEquals("ia.tfmc_armor:iron_light_helmet", ModelApplier.modelFor(scheme, light)); + var heavy = armour("heavy_helmet"); + assertEquals("v.chainmail_helmet.0", ModelApplier.modelFor(scheme, heavy)); + + var vanilla = ModelApplier.apply(new ItemStack(Material.LEATHER_HELMET), "v.iron_helmet.4"); + assertEquals(Material.IRON_HELMET, vanilla.getType()); + assertEquals(4, LegacyModelData.get(vanilla.getItemMeta())); + var ia = ModelApplier.apply(new ItemStack(Material.CHAINMAIL_HELMET), "ia.tfmc_armor:iron_light_helmet"); + assertEquals("tfmc_armor.iron_light_helmet", ia(ia)); + var unknown = new ItemStack(Material.CHAINMAIL_HELMET); + assertSame(unknown, ModelApplier.apply(unknown, "x.something")); + } + + @Test + void theConfigListsLegacyModelsPerRecipe() throws Exception { + var file = temp.resolve("migration.yml"); + Files.writeString( + file, + "armour-look-migration:\n enabled: true\n legacy-models:\n" + + " - Infantry_Helmet(v.carved_pumpkin.1)\n - infantry_helmet( v.golden_helmet.0 )\n" + + " - broken\n - (v.stone.0)\n - open(v.stone.0\n"); + Cache.armourLookMigration = false; + new ConfigLoader().load(file.toFile()); + assertTrue(Cache.armourLookMigration); + assertEquals(Map.of("infantry_helmet", List.of("v.carved_pumpkin.1", "v.golden_helmet.0")), Cache.legacyModels); + + Files.writeString(file, "scrap-path: x\n"); + new ConfigLoader().load(file.toFile()); + assertFalse(Cache.armourLookMigration); + assertTrue(Cache.legacyModels.isEmpty()); + } + + @Test + void theItemRefreshAlsoMigratesLooks() throws Exception { + // Not outdated: only the look migration runs. + var old = crafted("light_helmet", "iron", Material.CHAINMAIL_HELMET, null); + assertEquals("tfmc_armor.iron_light_helmet", ia(AcItemRefresher.refreshIfOutdated(old))); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/IaAutoUpdateTest.java b/src/test/java/net/tfminecraft/advancedcrafting/IaAutoUpdateTest.java new file mode 100644 index 0000000..7063281 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/IaAutoUpdateTest.java @@ -0,0 +1,66 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import de.tr7zw.nbtapi.NBT; +import de.tr7zw.nbtapi.iface.ReadWriteItemNBT; +import de.tr7zw.nbtapi.iface.ReadWriteNBT; +import de.tr7zw.nbtapi.iface.ReadableItemNBT; +import de.tr7zw.nbtapi.iface.ReadableNBT; +import java.util.function.Consumer; +import java.util.function.Function; +import net.tfminecraft.advancedcrafting.utils.IaAutoUpdate; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.Test; + +class IaAutoUpdateTest extends CoverageSupport { + static boolean exposed(ReadableItemNBT nbt) { + var item = mock(ItemStack.class); + try (var nbts = mockStatic(NBT.class)) { + nbts.when(() -> NBT.get(eq(item), any(Function.class))) + .thenAnswer(call -> ((Function) call.getArgument(1)).apply(nbt)); + return IaAutoUpdate.isExposed(item); + } + } + + static ReadableItemNBT withCompound(ReadableNBT compound) { + var nbt = mock(ReadableItemNBT.class); + when(nbt.hasTag("itemsadder")).thenReturn(true); + when(nbt.getCompound("itemsadder")).thenReturn(compound); + return nbt; + } + + static ReadableNBT flag(Boolean value) { + var compound = mock(ReadableNBT.class); + when(compound.getBoolean("override_auto_update")).thenReturn(value); + return compound; + } + + @Test + void onlyItemsAdderLooksWithoutTheOptOutAreExposed() { + assertFalse(exposed(mock(ReadableItemNBT.class)), "no ItemsAdder look"); + assertFalse(exposed(withCompound(null))); + assertTrue(exposed(withCompound(flag(null)))); + assertTrue(exposed(withCompound(flag(false)))); + assertFalse(exposed(withCompound(flag(true))), "already opted out"); + } + + @Test + void protectingSetsTheOptOut() { + var item = mock(ItemStack.class); + var nbt = mock(ReadWriteItemNBT.class); + var compound = mock(ReadWriteNBT.class); + when(nbt.getOrCreateCompound("itemsadder")).thenReturn(compound); + try (var nbts = mockStatic(NBT.class)) { + nbts.when(() -> NBT.modify(eq(item), any(Consumer.class))) + .thenAnswer( + call -> { + ((Consumer) call.getArgument(1)).accept(nbt); + return null; + }); + IaAutoUpdate.protect(item); + } + verify(compound).setBoolean("override_auto_update", true); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java index 4d1da98..08b9a0c 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java @@ -71,12 +71,21 @@ void craftedRefreshPreservesAmountSynchronizesProvenanceAndAppliesTier() throws var rebuilt = new ItemStack(Material.IRON_SWORD); var nbt = mock(NBTItem.class); try (var nbts = mockStatic(NBTItem.class); + var skins = mockStatic(net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver.class); + var models = mockStatic(net.tfminecraft.advancedcrafting.util.LegacyModelData.class); + var autoUpdate = mockStatic(IaAutoUpdate.class); var mmos = mockConstruction( LiveMMOItem.class, withSettings().defaultAnswer(RETURNS_DEEP_STUBS), (mock, ctx) -> when(mock.newBuilder().build()).thenReturn(rebuilt))) { nbts.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(nbt); + skins + .when( + () -> + net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver.apply( + any(ItemStack.class), any(ItemStack.class))) + .thenAnswer(call -> call.getArgument(1)); var result = CraftStatRefresher.refreshIfOutdated(item); assertTrue(result.isChanged()); assertNull(result.getError()); diff --git a/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java b/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java new file mode 100644 index 0000000..2834877 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java @@ -0,0 +1,172 @@ +package net.tfminecraft.advancedcrafting; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.Indyuce.mmoitems.stat.data.DoubleData; +import net.kyori.adventure.text.Component; +import net.tfminecraft.advancedcrafting.util.LegacyModelData; +import net.tfminecraft.advancedcrafting.utils.IaAutoUpdate; +import net.tfminecraft.advancedcrafting.utils.RefreshKeeper; +import net.tfminecraft.tlibs.objects.api.subapi.ItemSkinPreserver; +import org.bukkit.Color; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.LeatherArmorMeta; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +class RefreshKeeperTest extends CoverageSupport { + static final NamespacedKey MODEL = new NamespacedKey("test", "model"); + static final NamespacedKey GEMS = new NamespacedKey("geminfusion", "socket_rarities"); + + MockedStatic skins; + MockedStatic models; + MockedStatic autoUpdate; + + @BeforeEach + void mocks() { + autoUpdate = mockStatic(IaAutoUpdate.class); + autoUpdate + .when(() -> IaAutoUpdate.isExposed(any(ItemStack.class))) + .thenAnswer(call -> ((ItemStack) call.getArgument(0)).getType() == Material.LEATHER_HELMET); + skins = mockStatic(ItemSkinPreserver.class); + skins + .when(() -> ItemSkinPreserver.apply(any(ItemStack.class), any(ItemStack.class))) + .thenAnswer(call -> call.getArgument(1)); + // MockBukkit has no custom model data component; a PDC entry stands in for it. + models = mockStatic(LegacyModelData.class); + models + .when(() -> LegacyModelData.set(any(ItemMeta.class), any())) + .thenAnswer( + call -> { + ((ItemMeta) call.getArgument(0)) + .getPersistentDataContainer() + .set(MODEL, PersistentDataType.INTEGER, call.getArgument(1)); + return null; + }); + models + .when(() -> LegacyModelData.has(any(ItemMeta.class))) + .thenAnswer(call -> ((ItemMeta) call.getArgument(0)).getPersistentDataContainer().has(MODEL)); + models + .when(() -> LegacyModelData.get(any(ItemMeta.class))) + .thenAnswer( + call -> + ((ItemMeta) call.getArgument(0)) + .getPersistentDataContainer() + .get(MODEL, PersistentDataType.INTEGER)); + } + + @AfterEach + void close() { + skins.close(); + models.close(); + autoUpdate.close(); + } + + static NBTItem wear(Integer current, Integer max) { + var nbt = mock(NBTItem.class); + when(nbt.hasTag("MMOITEMS_DURABILITY")).thenReturn(current != null); + when(nbt.hasTag("MMOITEMS_MAX_DURABILITY")).thenReturn(max != null); + when(nbt.getInteger("MMOITEMS_DURABILITY")).thenReturn(current == null ? 0 : current); + when(nbt.getInteger("MMOITEMS_MAX_DURABILITY")).thenReturn(max == null ? 0 : max); + return nbt; + } + + static MMOItem rebuilt(Double max) { + var mmo = mock(MMOItem.class); + when(mmo.hasData(ItemStats.MAX_DURABILITY)).thenReturn(max != null); + if (max != null) when(mmo.getData(ItemStats.MAX_DURABILITY)).thenReturn(new DoubleData(max)); + return mmo; + } + + static Double kept(Integer current, Integer oldMax, Double newMax) { + var mmo = rebuilt(newMax); + RefreshKeeper.keepWear(wear(current, oldMax), mmo); + var captor = ArgumentCaptor.forClass(DoubleData.class); + verify(mmo, atMost(1)).setData(eq(ItemStats.CUSTOM_DURABILITY), captor.capture()); + return captor.getAllValues().isEmpty() ? null : captor.getValue().getValue(); + } + + @Test + void theDamageTakenCarriesOverToTheNewMaximum() { + assertEquals(461.0, kept(478, 500, 483.0)); + assertEquals(483.0, kept(500, 500, 483.0), "an undamaged item stays undamaged"); + assertEquals(520.0, kept(500, 500, 520.0)); + assertEquals(1.0, kept(5, 500, 400.0), "a refresh never breaks an item"); + assertEquals(0.0, kept(0, 500, 400.0), "a broken item stays broken"); + assertNull(kept(null, 500, 483.0)); + assertNull(kept(478, null, 483.0)); + assertNull(kept(478, 500, null)); + } + + @Test + void theLookNameAndForeignDataComeBackOnTheRebuiltItem() { + var old = new ItemStack(Material.LEATHER_HELMET); + var meta = (LeatherArmorMeta) old.getItemMeta(); + meta.setColor(Color.fromRGB(0xFFFFF3)); + meta.displayName(Component.text("Thalendorian Armor Iron Helmet")); + meta.getPersistentDataContainer().set(GEMS, PersistentDataType.STRING, "{\"a\":\"common\"}"); + meta.getPersistentDataContainer().set(PlainKeys.QUALITY, PersistentDataType.STRING, "old"); + LegacyModelData.set(meta, 10194); + old.setItemMeta(meta); + + var fresh = new ItemStack(Material.CHAINMAIL_HELMET); + var freshMeta = fresh.getItemMeta(); + freshMeta.getPersistentDataContainer().set(PlainKeys.QUALITY, PersistentDataType.STRING, "new"); + fresh.setItemMeta(freshMeta); + + var result = RefreshKeeper.keepAppearance(old, fresh); + assertEquals(Material.LEATHER_HELMET, result.getType()); + var kept = (LeatherArmorMeta) result.getItemMeta(); + assertEquals(Color.fromRGB(0xFFFFF3), kept.getColor()); + assertEquals(Component.text("Thalendorian Armor Iron Helmet"), kept.displayName()); + assertEquals(10194, LegacyModelData.get(kept)); + assertEquals("{\"a\":\"common\"}", kept.getPersistentDataContainer().get(GEMS, PersistentDataType.STRING)); + assertEquals( + "new", + kept.getPersistentDataContainer().get(PlainKeys.QUALITY, PersistentDataType.STRING), + "the rebuilt item's own data wins"); + autoUpdate.verify(() -> IaAutoUpdate.protect(result)); + + // A plain item without a model or name keeps what the rebuild made. + var plain = new ItemStack(Material.IRON_SWORD); + var plainResult = RefreshKeeper.keepAppearance(plain, new ItemStack(Material.IRON_SWORD)); + assertFalse(LegacyModelData.has(plainResult.getItemMeta())); + assertFalse(plainResult.getItemMeta().hasDisplayName()); + autoUpdate.verify(() -> IaAutoUpdate.protect(plainResult), never()); + } + + @Test + void itemsWithoutMetaOrWithMismatchedMetaAreLeftToTheRebuild() { + var noMeta = mock(ItemStack.class); + when(noMeta.getType()).thenReturn(Material.IRON_SWORD); + var fresh = new ItemStack(Material.IRON_SWORD); + assertSame(fresh, RefreshKeeper.keepAppearance(noMeta, fresh)); + + var old = new ItemStack(Material.LEATHER_HELMET); + var rebuiltNoMeta = mock(ItemStack.class); + when(rebuiltNoMeta.getType()).thenReturn(Material.LEATHER_HELMET); + assertSame(rebuiltNoMeta, RefreshKeeper.keepAppearance(old, rebuiltNoMeta)); + + // An old leather look whose rebuilt meta is not leather keeps the rebuilt colour handling. + var plainMeta = new ItemStack(Material.IRON_HELMET).getItemMeta(); + var odd = mock(ItemStack.class); + when(odd.getType()).thenReturn(Material.LEATHER_HELMET); + when(odd.getItemMeta()).thenReturn(plainMeta); + assertSame(odd, RefreshKeeper.keepAppearance(old, odd)); + verify(odd).setItemMeta(plainMeta); + } + + /** A key the rebuilt item sets itself. */ + static final class PlainKeys { + static final NamespacedKey QUALITY = new NamespacedKey("advancedcrafting", "ac_craft_quality"); + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java index 03446fd..69a5072 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/RefreshListenerCoverageTest.java @@ -158,6 +158,32 @@ void joinSweepRefreshesInventoryArmourOffhandAndEnderChestOneTickLater() throws verify(gone, never()).getInventory(); } + @Test + void aBrokenItemIsLoggedAndTheSweepCarriesOn() { + when(plugin.isEnabled()).thenReturn(true); + var p = server.addPlayer(); + var broken = new ItemStack(Material.IRON_HELMET); + var next = new ItemStack(Material.IRON_BOOTS); + var refreshed = new ItemStack(Material.LEATHER_BOOTS); + p.getInventory().setItem(0, broken); + p.getInventory().setItem(1, next); + try (var refresher = mockStatic(AcItemRefresher.class)) { + refresher.when(() -> AcItemRefresher.isManaged(any())).thenReturn(true); + refresher + .when(() -> AcItemRefresher.refreshIfOutdated(argThat(i -> i != null && i.getType() == Material.IRON_HELMET))) + .thenThrow(new NullPointerException("no MMOItems type")); + refresher + .when(() -> AcItemRefresher.refreshIfOutdated(argThat(i -> i != null && i.getType() == Material.IRON_BOOTS))) + .thenReturn(refreshed); + var join = mock(PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(p); + new CraftRefreshListener().onJoin(join); + server.getScheduler().performOneTick(); + } + assertEquals(Material.IRON_HELMET, p.getInventory().getItem(0).getType()); + assertEquals(Material.LEATHER_BOOTS, p.getInventory().getItem(1).getType()); + } + @Test void openSweepOnlyTouchesWorldStorageAfterTheEvent() throws Exception { when(plugin.isEnabled()).thenReturn(true);