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);