From 104f427144810b8733b285e97282c63f5ce86542 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:35:43 +0200 Subject: [PATCH 1/4] Move crafted armour still in its old default look to the per-recipe model Pieces crafted before the per-recipe models still wear their metal's shared type model (iron chainmail, steel iron, bronze bronze_*, ...) or the infantry paper / mage dust look. When AcItemRefresher sees a recorded craft (join, ender chest, opened storage, click, equip), ArmourLookMigrator gives it the recipe's current model through ArmorMerger, so its MMOItems data stays. Any other look is a skin and is kept. The old look is saved in ac_previous_model and logged. Every recorded craft also gets its model scheme tag, so ArmourShop's metal skin lines apply to older pieces too. armour-look-migration.enabled is off by default; legacy-models lists the old looks of recipes whose look came from another ingredient. Co-Authored-By: Claude Opus 5.5 --- .../advancedcrafting/cache/Cache.java | 5 + .../loaders/ConfigLoader.java | 13 + .../objects/crafting/CraftingStation.java | 25 +- .../utils/AcItemRefresher.java | 6 +- .../utils/ArmourLookMigrator.java | 124 +++++++ .../advancedcrafting/utils/ModelApplier.java | 39 ++ .../advancedcrafting/utils/PDCKeys.java | 5 + src/main/resources/config.yml | 17 + .../ArmourLookMigratorTest.java | 337 ++++++++++++++++++ 9 files changed, 546 insertions(+), 25 deletions(-) create mode 100644 src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java create mode 100644 src/main/java/net/tfminecraft/advancedcrafting/utils/ModelApplier.java create mode 100644 src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java 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/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..3515335 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java @@ -0,0 +1,124 @@ +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. + */ +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); + } + 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 = Integer.parseInt(parts[2]); + 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/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/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/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java new file mode 100644 index 0000000..b2f0234 --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java @@ -0,0 +1,337 @@ +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"); + + MockedStatic nbts; + MockedStatic models; + CraftingRecipe light; + CraftingRecipe infantry; + + @BeforeEach + void world() throws Exception { + 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(); + } + + 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()); + + // 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())); + assertSame(tagged, ArmourLookMigrator.migrate(tagged)); + 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()); + 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, "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))); + } +} From 4246169b3e60e2010ac3edc0639654ece90aaa92 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:52:18 +0200 Subject: [PATCH 2/4] Keep wear, skin, name and foreign data when a stat refresh rebuilds an item CraftStatRefresher rebuilt outdated crafted items from their MMOItems data only. On TFMCDev01 that repaired them to full durability, dropped ArmourShop skin names, the itemsadder compound of skins, and other plugins' data such as geminfusion:socket_rarities and tlibs:socket_overrides. RefreshKeeper keeps the damage taken (scaled to the new maximum, never breaking an item), then puts back the skin through TLibs' ItemSkinPreserver, the material, model and leather colour, the display name, and every persistent data key the rebuilt item does not set itself. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 7 + .../utils/CraftStatRefresher.java | 6 +- .../advancedcrafting/utils/RefreshKeeper.java | 65 +++++++ .../advancedcrafting/MmoCoverageTest.java | 8 + .../advancedcrafting/RefreshKeeperTest.java | 163 ++++++++++++++++++ 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java create mode 100644 src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java diff --git a/pom.xml b/pom.xml index e6296fc..c86c783 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,13 @@ + + + local + item-nbt-api-plugin + 2.16.1-tfmc-c571502e7686 + test + beer.devs 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/RefreshKeeper.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java new file mode 100644 index 0000000..b8687f3 --- /dev/null +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java @@ -0,0 +1,65 @@ +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); + return kept; + } +} diff --git a/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java index 4d1da98..151bb45 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java @@ -71,12 +71,20 @@ 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 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..cc701ff --- /dev/null +++ b/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java @@ -0,0 +1,163 @@ +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.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; + + @BeforeEach + void mocks() { + 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(); + } + + 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"); + + // 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()); + } + + @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"); + } +} From 83a9684dd90405d5a8e246ec3af30136949f0705 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:10:47 +0200 Subject: [PATCH 3/4] Opt crafted pieces with an ItemsAdder look out of ItemsAdder auto_update ItemsAdder's auto_update rebuilds an item from its definition when the definition's base properties hash no longer matches the one it stored for that player slot. The rebuild keeps only amount, durability, persistent data, trim, name and enchantments, so a crafted MMOItems piece wearing an ItemsAdder look loses its stats, gems and lore (the 2026-10-03 incident, and reproduced on TFMCDev01 by editing a definition or a player's inventory offline). ItemsAdder skips items whose itemsadder compound has override_auto_update: true. The look migration sets that flag on every recorded craft with an ItemsAdder look (migrated and skinned pieces), and the stat refresh sets it on rebuilt pieces. NBTAPI becomes a provided dependency and a softdepend. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 18 ++++- .../utils/ArmourLookMigrator.java | 9 ++- .../advancedcrafting/utils/IaAutoUpdate.java | 38 +++++++++++ .../advancedcrafting/utils/RefreshKeeper.java | 3 + src/main/resources/plugin.yml | 1 + .../ArmourLookMigratorTest.java | 33 ++++++++++ .../advancedcrafting/IaAutoUpdateTest.java | 66 +++++++++++++++++++ .../advancedcrafting/MmoCoverageTest.java | 1 + .../advancedcrafting/RefreshKeeperTest.java | 9 +++ 9 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/tfminecraft/advancedcrafting/utils/IaAutoUpdate.java create mode 100644 src/test/java/net/tfminecraft/advancedcrafting/IaAutoUpdateTest.java diff --git a/pom.xml b/pom.xml index c86c783..b4a469b 100644 --- a/pom.xml +++ b/pom.xml @@ -17,12 +17,22 @@ - + + + com.mojang + authlib + 7.0.61 + test + + ** + + + local item-nbt-api-plugin 2.16.1-tfmc-c571502e7686 - test + provided @@ -106,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/utils/ArmourLookMigrator.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java index 3515335..b4aa57b 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java @@ -21,7 +21,8 @@ * 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. + * 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() { @@ -61,6 +62,12 @@ public static ItemStack migrate(ItemStack item) { 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; } 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/RefreshKeeper.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java index b8687f3..50cdcda 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/RefreshKeeper.java @@ -60,6 +60,9 @@ public static ItemStack keepAppearance(ItemStack old, ItemStack rebuilt) { } oldMeta.getPersistentDataContainer().copyTo(meta.getPersistentDataContainer(), false); kept.setItemMeta(meta); + if (IaAutoUpdate.isExposed(kept)) { + IaAutoUpdate.protect(kept); + } return kept; } } 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 index b2f0234..3956cf7 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java @@ -28,14 +28,35 @@ class ArmourLookMigratorTest extends CoverageSupport { 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())) @@ -111,6 +132,7 @@ void world() throws Exception { void closeNbt() { nbts.close(); models.close(); + autoUpdate.close(); } CraftingRecipe armour(String id) throws Exception { @@ -166,6 +188,7 @@ void oldDefaultLooksTakeTheRecipeModelAndKeepARecordOfTheOldOne() { 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)); @@ -190,13 +213,23 @@ void skinsAndOtherLooksStayButStillGetTheMetalTag() { 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"); 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 151bb45..08b9a0c 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/MmoCoverageTest.java @@ -73,6 +73,7 @@ void craftedRefreshPreservesAmountSynchronizesProvenanceAndAppliesTier() throws 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, diff --git a/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java b/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java index cc701ff..2834877 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/RefreshKeeperTest.java @@ -9,6 +9,7 @@ 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; @@ -28,9 +29,14 @@ class RefreshKeeperTest extends CoverageSupport { 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))) @@ -62,6 +68,7 @@ void mocks() { void close() { skins.close(); models.close(); + autoUpdate.close(); } static NBTItem wear(Integer current, Integer max) { @@ -127,12 +134,14 @@ void theLookNameAndForeignDataComeBackOnTheRebuiltItem() { "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 From 571d50d6a1d70e9213970d45fa665933491fdf82 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:24:53 +0200 Subject: [PATCH 4/4] Keep a join sweep going past a broken item and ignore malformed model paths A recorded craft whose MMOItems data is gone threw in the stat refresh and stopped the whole join sweep on TFMCDev01, so the rest of that inventory was not refreshed or migrated. CraftRefreshListener now logs the item and carries on. A v.. path with a non-numeric model no longer throws in the look migration; it simply does not match. Co-Authored-By: Claude Opus 5.5 --- .../managers/CraftRefreshListener.java | 9 ++++++- .../utils/ArmourLookMigrator.java | 7 ++++- .../ArmourLookMigratorTest.java | 1 + .../RefreshListenerCoverageTest.java | 26 +++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) 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/utils/ArmourLookMigrator.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java index b4aa57b..0b85229 100644 --- a/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java +++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/ArmourLookMigrator.java @@ -97,7 +97,12 @@ static boolean wears(ItemStack item, String path) { || !item.getType().name().equalsIgnoreCase(parts[1])) { return false; } - int model = Integer.parseInt(parts[2]); + 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; diff --git a/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java index 3956cf7..e51c295 100644 --- a/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java +++ b/src/test/java/net/tfminecraft/advancedcrafting/ArmourLookMigratorTest.java @@ -305,6 +305,7 @@ void looksAreMatchedAndDescribedByTheirSchemePath() throws Exception { 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)); 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);