diff --git a/.github/dependencies.sha256 b/.github/dependencies.sha256
index fd19572..f42aed5 100644
--- a/.github/dependencies.sha256
+++ b/.github/dependencies.sha256
@@ -3,3 +3,4 @@
a37f7789fcdcd11c9e5890a2fac742aa4501749588af23182991953e06ee1d17 libs/MMOItems-6.10.1-SNAPSHOT.jar
225aa7f75d4e6e6ae6b3ad87a7ca678c025c027f00374ab1062275d24f3a73c9 libs/MythicLib-1.7.1-SNAPSHOT.jar
5a01b37bd7442fb0f2c809cadac82ba70f75173956e04efac3336595cc070368 libs/ItemsAdder-4.0.18.jar
+c571502e768640c7cff10c49b3eef904ccf3fd95bd81803913abfe43cfc09746 libs/NBTAPI-2.16.1.jar
diff --git a/.github/scripts/install-local-dependencies.sh b/.github/scripts/install-local-dependencies.sh
index fbb1b1c..cfbca14 100644
--- a/.github/scripts/install-local-dependencies.sh
+++ b/.github/scripts/install-local-dependencies.sh
@@ -19,3 +19,6 @@ mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.
mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \
-Dfile="libs/ItemsAdder-4.0.18.jar" -DgroupId="local" -DartifactId="LoneLibs" \
-Dversion="4.0.18-tfmc-5a01b37bd744" -Dpackaging=jar -DgeneratePom=true "$@"
+mvn -B --no-transfer-progress org.apache.maven.plugins:maven-install-plugin:3.1.4:install-file \
+ -Dfile="libs/NBTAPI-2.16.1.jar" -DgroupId="local" -DartifactId="item-nbt-api-plugin" \
+ -Dversion="2.16.1-tfmc-c571502e7686" -Dpackaging=jar -DgeneratePom=true "$@"
diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh
index 5250a35..a3a7416 100755
--- a/.github/scripts/prepare-release.sh
+++ b/.github/scripts/prepare-release.sh
@@ -8,4 +8,5 @@ curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer
curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/a37f7789fcdc/MMOItems-6.10.1-SNAPSHOT.jar?ref=$ref" > "libs/MMOItems-6.10.1-SNAPSHOT.jar"
curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/225aa7f75d4e/MythicLib-1.7.1-SNAPSHOT.jar?ref=$ref" > "libs/MythicLib-1.7.1-SNAPSHOT.jar"
curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/5a01b37bd744/ItemsAdder-4.0.18.jar?ref=$ref" > "libs/ItemsAdder-4.0.18.jar"
+curl --fail --location --silent --show-error --retry 3 -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github.raw+json" "https://api.github.com/repos/TF-Minecraft/ServerAssets/contents/jars/c571502e7686/NBTAPI-2.16.1.jar?ref=$ref" > "libs/NBTAPI-2.16.1.jar"
bash .github/scripts/install-local-dependencies.sh "$@"
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 b080ea9..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,8 @@
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;
@@ -506,20 +504,7 @@ private StationFeedback createItem(Player p, Double forcedQualityPercent, boolea
mmo.setData(ItemStats.LORE, lore);
ItemStack finalItem = mmo.newBuilder().build();
if(scheme != null) {
- // A recipe-specific model on the main material wins over the secondary model ingredient.
- if(scheme.getModel(recipe.getId()) == null && !recipe.getModelType().equalsIgnoreCase("none")) {
- for(String s : currentMaterials.keySet()) {
- String modeltype = s.split("\\.")[0];
- String modelId = s.split("\\.")[1];
- if(modeltype.equalsIgnoreCase("ingredient")) {
- Ingredient ing = IngredientLoader.getByString(modelId);
- if(ing.getIngredientData().getType().getId().equalsIgnoreCase(recipe.getModelType())) scheme = ing.getIngredientData().getModelScheme();
- } else if(modeltype.equalsIgnoreCase("alloy")) {
- Alloy a = AlloyManager.getAlloyById(modelId);
- if(a.getData().getType().getId().equalsIgnoreCase(recipe.getModelType())) scheme = a.getData().getModelScheme();
- }
- }
- }
+ scheme = ModelSchemeResolver.forModel(recipe, scheme, currentMaterials);
finalItem = applyModel(finalItem, scheme);
}
CraftProvenance provenance = CraftProvenance.from(recipe, currentMaterials, q);
@@ -539,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/objects/data/CraftProvenance.java b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/CraftProvenance.java
index 6fe6dc3..46fb7c8 100644
--- a/src/main/java/net/tfminecraft/advancedcrafting/objects/data/CraftProvenance.java
+++ b/src/main/java/net/tfminecraft/advancedcrafting/objects/data/CraftProvenance.java
@@ -18,7 +18,9 @@
import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe;
import net.tfminecraft.advancedcrafting.objects.crafting.Quality;
import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient;
+import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme;
import net.tfminecraft.advancedcrafting.objects.stats.StatTemplate;
+import net.tfminecraft.advancedcrafting.utils.ModelSchemeResolver;
import net.tfminecraft.advancedcrafting.utils.PDCKeys;
public class CraftProvenance {
@@ -101,6 +103,14 @@ public void applyTo(ItemStack item) {
meta.getPersistentDataContainer().set(PDCKeys.craftInputs(), PersistentDataType.STRING, GSON.toJson(inputs));
meta.getPersistentDataContainer().set(PDCKeys.craftStatTemplateRevision(), PersistentDataType.INTEGER,
statTemplateRevision);
+ ModelScheme scheme = ModelSchemeResolver.resolve(RecipeLoader.getByString(recipeId), inputs);
+ if (scheme != null) {
+ meta.getPersistentDataContainer().set(PDCKeys.craftModelScheme(), PersistentDataType.STRING,
+ scheme.getId());
+ } else {
+ // A recipe or ingredient that no longer resolves must not leave an old metal for ArmourShop to trust.
+ meta.getPersistentDataContainer().remove(PDCKeys.craftModelScheme());
+ }
item.setItemMeta(meta);
}
@@ -122,7 +132,12 @@ public static CraftProvenance readFrom(ItemStack item) {
if (inputsJson != null) {
List parsed = GSON.fromJson(inputsJson, new TypeToken>() {}.getType());
if (parsed != null) {
- inputs = parsed;
+ // Every refresh consumer reads these inputs; a hand-edited or truncated record must not break them.
+ for (CraftInput input : parsed) {
+ if (input != null && input.getKind() != null && input.getId() != null) {
+ inputs.add(input);
+ }
+ }
}
}
Integer templateRevision = meta.getPersistentDataContainer().get(PDCKeys.craftStatTemplateRevision(),
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..8df924a
--- /dev/null
+++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/IaAutoUpdate.java
@@ -0,0 +1,47 @@
+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) {
+ try {
+ 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));
+ });
+ } catch (LinkageError missingNbtApi) {
+ // NBTAPI is a soft dependency; without it there is no opt-out to set.
+ return false;
+ }
+ }
+
+ /** Opts the item out of ItemsAdder's auto_update. */
+ public static void protect(ItemStack item) {
+ try {
+ NBT.modify(item, nbt -> {
+ nbt.getOrCreateCompound(COMPOUND).setBoolean(OVERRIDE, true);
+ });
+ } catch (LinkageError missingNbtApi) {
+ // NBTAPI is a soft dependency; without it the item simply stays as it is.
+ }
+ }
+}
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/ModelSchemeResolver.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/ModelSchemeResolver.java
new file mode 100644
index 0000000..f60a225
--- /dev/null
+++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/ModelSchemeResolver.java
@@ -0,0 +1,79 @@
+package net.tfminecraft.advancedcrafting.utils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import net.tfminecraft.advancedcrafting.loaders.IngredientLoader;
+import net.tfminecraft.advancedcrafting.managers.AlloyManager;
+import net.tfminecraft.advancedcrafting.objects.alloys.Alloy;
+import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe;
+import net.tfminecraft.advancedcrafting.objects.data.CraftInput;
+import net.tfminecraft.advancedcrafting.objects.ingredients.Ingredient;
+import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme;
+
+/**
+ * Picks the model scheme whose look a crafted item wears: the main material's scheme, unless that scheme
+ * has no model for the recipe and a material of the recipe's model type brings its own. Alloys use their
+ * base's scheme, so an alloy piece wears (and is tagged with) its base metal.
+ */
+public final class ModelSchemeResolver {
+ private ModelSchemeResolver() {
+ }
+
+ /** The scheme of a recorded craft, or null when the recipe or inputs are unknown. */
+ public static ModelScheme resolve(CraftingRecipe recipe, List inputs) {
+ if (recipe == null || inputs == null || inputs.isEmpty()) {
+ return null;
+ }
+ HashMap materials = new HashMap<>();
+ for (CraftInput input : inputs) {
+ if (input == null || input.getKind() == null || input.getId() == null) {
+ continue;
+ }
+ materials.put(input.getKind().toLowerCase() + "." + input.getId().toLowerCase(), input.getAmount());
+ }
+ ModelScheme main = schemeOf(MajorityTierResolver.resolveMajorityKey(recipe, materials), null, null);
+ return forModel(recipe, main, materials);
+ }
+
+ /**
+ * A recipe-specific model on the main material wins over the secondary model ingredient. A model-type
+ * material only takes over when its scheme has a model for the recipe, so one without a scheme (or with an
+ * empty one) never leaves the item without a look.
+ */
+ public static ModelScheme forModel(CraftingRecipe recipe, ModelScheme scheme, Map materials) {
+ if (scheme == null || scheme.getModel(recipe.getId()) != null
+ || recipe.getModelType().equalsIgnoreCase("none")) {
+ return scheme;
+ }
+ for (String key : materials.keySet()) {
+ ModelScheme candidate = schemeOf(key, recipe.getModelType(), null);
+ if (candidate != null && ModelApplier.modelFor(candidate, recipe) != null) {
+ scheme = candidate;
+ }
+ }
+ return scheme;
+ }
+
+ /** The scheme of one material key if it exists and (when given) has the wanted type, else the fallback. */
+ private static ModelScheme schemeOf(String key, String wantedType, ModelScheme fallback) {
+ String[] split = key.split("\\.");
+ if (split.length < 2) {
+ return fallback;
+ }
+ if (split[0].equalsIgnoreCase("ingredient")) {
+ Ingredient ing = IngredientLoader.getByString(split[1]);
+ if (ing != null && (wantedType == null
+ || ing.getIngredientData().getType().getId().equalsIgnoreCase(wantedType))) {
+ return ing.getIngredientData().getModelScheme();
+ }
+ } else if (split[0].equalsIgnoreCase("alloy")) {
+ Alloy a = AlloyManager.getAlloyById(split[1]);
+ if (a != null && (wantedType == null || a.getData().getType().getId().equalsIgnoreCase(wantedType))) {
+ return a.getData().getModelScheme();
+ }
+ }
+ return fallback;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java b/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java
index c737a7e..672f9bb 100644
--- a/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java
+++ b/src/main/java/net/tfminecraft/advancedcrafting/utils/PDCKeys.java
@@ -24,6 +24,16 @@ public static NamespacedKey craftStatTemplateRevision() {
return new NamespacedKey(AdvancedCrafting.plugin, "ac_craft_stat_template_revision");
}
+ /** Model scheme the crafted item wears (iron, steel, ...); alloys carry their base's. Read by ArmourShop. */
+ 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..29ed21c
--- /dev/null
+++ b/src/test/java/net/tfminecraft/advancedcrafting/IaAutoUpdateTest.java
@@ -0,0 +1,77 @@
+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);
+ }
+
+ @Test
+ void withoutNbtApiNothingIsExposedOrChanged() {
+ var item = mock(ItemStack.class);
+ try (var nbts = mockStatic(NBT.class)) {
+ nbts.when(() -> NBT.get(eq(item), any(Function.class))).thenThrow(new NoClassDefFoundError("NBT"));
+ nbts.when(() -> NBT.modify(eq(item), any(Consumer.class))).thenThrow(new NoClassDefFoundError("NBT"));
+ assertFalse(IaAutoUpdate.isExposed(item));
+ assertDoesNotThrow(() -> IaAutoUpdate.protect(item));
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java
index 7ae22fe..5ef0600 100644
--- a/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java
+++ b/src/test/java/net/tfminecraft/advancedcrafting/ItemMetadataCoverageTest.java
@@ -223,6 +223,25 @@ void craftProvenanceRoundTripsAndDetectsIngredientAndTemplateRevisions() throws
meta.getPersistentDataContainer().set(PDCKeys.craftInputs(), PersistentDataType.STRING, "null");
minimal.setItemMeta(meta);
assertTrue(CraftProvenance.readFrom(minimal).getInputs().isEmpty());
+ // Malformed records (null entries, kinds or ids) are dropped instead of breaking every refresh.
+ meta = minimal.getItemMeta();
+ meta.getPersistentDataContainer()
+ .set(
+ PDCKeys.craftInputs(),
+ PersistentDataType.STRING,
+ "[null,{\"id\":\"iron\",\"n\":1},{\"k\":\"ingredient\",\"n\":1},"
+ + "{\"k\":\"ingredient\",\"id\":\"iron\",\"n\":2,\"r\":1}]");
+ minimal.setItemMeta(meta);
+ var kept = CraftProvenance.readFrom(minimal).getInputs();
+ assertEquals(1, kept.size());
+ assertEquals("iron", kept.get(0).getId());
+ // A record whose recipe no longer resolves loses its old scheme tag.
+ meta = minimal.getItemMeta();
+ meta.getPersistentDataContainer().set(PDCKeys.craftModelScheme(), PersistentDataType.STRING, "old");
+ minimal.setItemMeta(meta);
+ CraftProvenance.readFrom(minimal).applyTo(minimal);
+ assertNull(
+ minimal.getItemMeta().getPersistentDataContainer().get(PDCKeys.craftModelScheme(), PersistentDataType.STRING));
assertNotNull(new CraftProvenance().getInputs());
}
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/ModelSchemeResolverTest.java b/src/test/java/net/tfminecraft/advancedcrafting/ModelSchemeResolverTest.java
new file mode 100644
index 0000000..0f6051e
--- /dev/null
+++ b/src/test/java/net/tfminecraft/advancedcrafting/ModelSchemeResolverTest.java
@@ -0,0 +1,89 @@
+package net.tfminecraft.advancedcrafting;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import net.tfminecraft.advancedcrafting.loaders.*;
+import net.tfminecraft.advancedcrafting.managers.AlloyManager;
+import net.tfminecraft.advancedcrafting.objects.alloys.Alloy;
+import net.tfminecraft.advancedcrafting.objects.crafting.CraftingRecipe;
+import net.tfminecraft.advancedcrafting.objects.data.*;
+import net.tfminecraft.advancedcrafting.objects.ingredients.*;
+import net.tfminecraft.advancedcrafting.objects.schemes.ModelScheme;
+import net.tfminecraft.advancedcrafting.utils.ModelSchemeResolver;
+import org.junit.jupiter.api.Test;
+
+class ModelSchemeResolverTest extends CoverageSupport {
+ static CraftInput in(String kind, String id, int amount) {
+ return new CraftInput(kind, id, amount, 0);
+ }
+
+ static String scheme(CraftingRecipe recipe, CraftInput... inputs) {
+ ModelScheme scheme = ModelSchemeResolver.resolve(recipe, List.of(inputs));
+ return scheme == null ? null : scheme.getId();
+ }
+
+ Alloy alloy(String id, Ingredient base) {
+ var alloy = new Alloy(id, id, new AlloyData(base, new StatData(), new HashMap<>(), null));
+ AlloyManager.addAlloy(alloy);
+ return alloy;
+ }
+
+ @Test
+ void recordedCraftsResolveTheSchemeTheirLookCameFromAndAlloysUseTheirBase() throws Exception {
+ TypeLoader.map.put("paper", new IngredientType("paper", yaml("name: Paper")));
+ SchemeLoader.models.put("iron", new ModelScheme("iron", yaml("models: ['smith(v.iron_sword.1)']")));
+ SchemeLoader.models.put("own", new ModelScheme("own", yaml("models: ['sword(v.iron_sword.2)']")));
+ SchemeLoader.models.put("cloth", new ModelScheme("cloth", yaml("models: ['smith(v.golden_sword.3)']")));
+ ingredient("iron", "model-scheme: iron");
+ ingredient("own", "model-scheme: own");
+ var cloth = ingredient("cloth", "type: paper\nmodel-scheme: cloth");
+ alloy("darksteel", IngredientLoader.getByString("iron"));
+ alloy("clothsteel", cloth);
+ var noModel = recipe("recipe: ['metal.1', 'paper.1']\nmodel-type: none");
+ var byPaper = recipe("recipe: ['metal.1', 'paper.1']\nmodel-type: paper");
+
+ assertNull(ModelSchemeResolver.resolve(null, List.of(in("ingredient", "iron", 1))));
+ assertNull(ModelSchemeResolver.resolve(noModel, null));
+ assertNull(ModelSchemeResolver.resolve(noModel, List.of()));
+
+ // The main material decides; an alloy wears its base metal's scheme.
+ assertEquals("iron", scheme(noModel, in("ingredient", "iron", 3), in("ingredient", "cloth", 1)));
+ assertEquals("iron", scheme(noModel, in("alloy", "darksteel", 3)));
+ // A model-type material takes over when the main scheme has no model for the recipe ...
+ assertEquals("cloth", scheme(byPaper, in("alloy", "darksteel", 3), in("ingredient", "cloth", 1)));
+ assertEquals("cloth", scheme(byPaper, in("ingredient", "iron", 3), in("alloy", "clothsteel", 1)));
+ // ... but not when it has one of its own.
+ assertEquals("own", scheme(byPaper, in("ingredient", "own", 3), in("ingredient", "cloth", 1)));
+ // Unknown, malformed and foreign inputs leave the main scheme alone.
+ assertEquals(
+ "iron",
+ scheme(
+ byPaper,
+ in("ingredient", "iron", 3),
+ in("alloy", "darksteel", 1),
+ in("ingredient", "gone", 1),
+ in("alloy", "gone", 1),
+ in("ingredient", "", 1),
+ in("misc", "x", 1)));
+ assertNull(scheme(noModel, in("ingredient", "gone", 1)));
+ // A model-type material whose scheme has no model for the recipe (an unknown scheme falls back to an
+ // empty default) or no scheme at all keeps the main scheme.
+ SchemeLoader.models.put("default", new ModelScheme("default", yaml("models: []")));
+ var blank = ingredient("blank", "type: paper\nmodel-scheme: missing");
+ alloy("blankalloy", blank);
+ assertEquals("iron", scheme(byPaper, in("ingredient", "iron", 3), in("ingredient", "blank", 1)));
+ assertEquals("iron", scheme(byPaper, in("ingredient", "iron", 3), in("alloy", "blankalloy", 1)));
+ SchemeLoader.models.remove("default");
+ ingredient("schemeless", "type: paper\nmodel-scheme: missing");
+ assertEquals("iron", scheme(byPaper, in("ingredient", "iron", 3), in("ingredient", "schemeless", 1)));
+ // Malformed inputs are skipped.
+ assertEquals(
+ "iron",
+ ModelSchemeResolver.resolve(
+ noModel,
+ Arrays.asList(null, in(null, "iron", 1), in("ingredient", null, 1), in("ingredient", "iron", 3)))
+ .getId());
+ assertNull(scheme(noModel, in("alloy", "gone", 1)));
+ }
+}
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);
diff --git a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java
index 4557004..e6ecdd3 100644
--- a/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java
+++ b/src/test/java/net/tfminecraft/advancedcrafting/StationCoverageTest.java
@@ -404,12 +404,11 @@ void mainMaterialRecipeModelWinsAndSecondaryIngredientRemainsFallback() throws E
specific
? Material.DIAMOND_SWORD
: modelType.equals("none") ? Material.IRON_SWORD : Material.GOLDEN_SWORD;
+ var craftRecipe = recipe("recipe: ['metal.1', 'paper.1']\nmodel-type: " + modelType);
+ RecipeLoader.map.put(craftRecipe.getId(), craftRecipe);
var station =
new CraftingStation(
- loc(),
- recipe("recipe: ['metal.1', 'paper.1']\nmodel-type: " + modelType),
- new HashMap<>(Map.of(key, 1, secondary, 1)),
- new HashMap<>());
+ loc(), craftRecipe, new HashMap<>(Map.of(key, 1, secondary, 1)), new HashMap<>());
station.getCurrentMaterials().put("unknown.removed", 1);
try (var nbt = mockStatic(NBTItem.class);
var models = mockStatic(LegacyModelData.class);
@@ -425,6 +424,13 @@ void mainMaterialRecipeModelWinsAndSecondaryIngredientRemainsFallback() throws E
var result = station.buildCompletedItem(p, 50.);
assertEquals(expectedMaterial, result.getType());
models.verify(() -> LegacyModelData.set(any(), eq(expectedModel)));
+ // The tag names the scheme whose look the item got; alloys carry their base's.
+ assertEquals(
+ expectedModel == 3 ? "cloth-look" : "metal-look",
+ result
+ .getItemMeta()
+ .getPersistentDataContainer()
+ .get(PDCKeys.craftModelScheme(), PersistentDataType.STRING));
}
}
}