diff --git a/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java b/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java index 88ab08f..f0a5e7b 100644 --- a/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java +++ b/src/main/java/net/tfminecraft/magic/gear/ArchetypeDef.java @@ -18,6 +18,7 @@ public final class ArchetypeDef { private final boolean melee; private final List required; private final Map slots; + private final String itemName; private int revision = 1; public ArchetypeDef( @@ -28,8 +29,23 @@ public ArchetypeDef( boolean melee, List required, Map slots) { + this(type, name, template, icon, melee, required, slots, ""); + } + + public ArchetypeDef( + GearType type, + String name, + String template, + String icon, + boolean melee, + List required, + Map slots, + String itemName) { this.type = type; this.name = name == null || name.isBlank() ? type.getDisplayName() : name; + this.itemName = itemName == null || itemName.isBlank() + ? GearName.MATERIAL + " " + this.name + : itemName; this.template = template == null ? "" : template.trim(); this.icon = icon == null ? "" : icon.trim(); this.melee = melee; @@ -66,6 +82,11 @@ public boolean isMelee() { return melee; } + /** Crafted weapon name, with {@link GearName#MATERIAL} standing for the core's material. */ + public String getItemName() { + return itemName; + } + public List getRequired() { return required; } @@ -93,7 +114,7 @@ public void setRevision(int revision) { this.revision = revision; } - /** Canonical string of the fields that change gameplay. Name is left out. */ + /** Canonical string of the fields that change gameplay. Name and item name are left out. */ public String buildRevisionContent() { StringBuilder out = new StringBuilder(); out.append("template=").append(template).append(';'); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java b/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java index 5675a4c..c853445 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearItemBuilder.java @@ -45,9 +45,14 @@ public static ItemStack rewriteSockets(ItemStack stack, int band) { } List parts = GearProvenance.resolveParts(stack); List colours = SocketLayout.colours(archetype, parts, band); - ItemStack rewritten = applyMmoData(stack, colours, parts); + String name = GearName.expected(type, parts); + ItemStack rewritten = applyMmoData(stack, colours, parts, name); + boolean named = rewritten != stack; rewritten = GearSkin.keep(stack, GearModelResolver.apply(rewritten, type, parts)); copyGearPdc(stack, rewritten); + if (named) { + GearName.stamp(rewritten, name); + } GearProvenance.lockSockets(rewritten); WeaponRequirement.fromItem(stack).persist(rewritten); WeaponRift.copy(stack, rewritten); @@ -75,9 +80,14 @@ private static ItemStack build(GearType type, Collection parts, boolean return decoratePreview(base, type, parts); } List colours = SocketLayout.colours(archetype, parts, band); - ItemStack withMmo = applyMmoData(base, colours, parts); + String name = GearName.expected(type, parts); + ItemStack withMmo = applyMmoData(base, colours, parts, name); + boolean named = withMmo != base; withMmo = GearModelResolver.apply(withMmo, type, parts); GearProvenance.stamp(withMmo, type, parts); + if (named) { + GearName.stamp(withMmo, name); + } return WeaponLore.updateItem(withMmo); } @@ -111,7 +121,9 @@ private static boolean requiredPresent(ArchetypeDef archetype, Collection colours, Collection parts) { + /** The rebuilt item, or the same stack when MMOItems is missing or the rebuild failed. */ + private static ItemStack applyMmoData( + ItemStack stack, List colours, Collection parts, String name) { if (stack == null || !mmoItemsPresent()) { return stack; } @@ -121,6 +133,7 @@ private static ItemStack applyMmoData(ItemStack stack, List colours, Col mmo.setData(ItemStats.GEM_SOCKETS, new GemSocketsData(new ArrayList<>(colours))); } GearStatApplicator.apply(mmo, parts); + GearName.write(mmo, name); ItemStack built = mmo.newBuilder().build(); return built == null || built.getType().isAir() ? stack : built; } catch (Exception ex) { @@ -194,7 +207,10 @@ private static ItemStack decoratePreview( lore.add("§cMMOItems is not loaded"); } meta.setLore(lore); - if (meta.getDisplayName().isBlank()) { + String name = GearName.expected(type, parts); + if (name != null) { + meta.setDisplayName(name); + } else if (meta.getDisplayName().isBlank()) { meta.setDisplayName("§6" + type.getDisplayName()); } stack.setItemMeta(meta); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java index 90f715d..77038d4 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java @@ -41,6 +41,11 @@ public static NamespacedKey archetypeRevision() { return new NamespacedKey(Magic.plugin, "gear_archetype_revision"); } + /** Name Magic last wrote on the weapon, so a refresh can tell when it is stale. */ + public static NamespacedKey gearName() { + return new NamespacedKey(Magic.plugin, "gear_name"); + } + public static NamespacedKey majorityTier() { return new NamespacedKey(Magic.plugin, "majority_tier"); } diff --git a/src/main/java/net/tfminecraft/magic/gear/GearName.java b/src/main/java/net/tfminecraft/magic/gear/GearName.java new file mode 100644 index 0000000..8206dc2 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/gear/GearName.java @@ -0,0 +1,107 @@ +package net.tfminecraft.magic.gear; + +import java.util.Collection; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.Indyuce.mmoitems.stat.type.NameData; +import net.Indyuce.mmoitems.stat.type.StatHistory; + +/** + * Names a mage weapon after its core, the way AdvancedCrafting names equipment after its + * majority material: the archetype's item name with {@link #MATERIAL} filled in, all of it + * in the material's colour. + */ +public final class GearName { + + public static final String MATERIAL = "%material%"; + + private static final Pattern LEADING_CODES = Pattern.compile("^(?:§[0-9a-fk-orx])+", Pattern.CASE_INSENSITIVE); + private static final Pattern CORE_SUFFIX = Pattern.compile("\\s+(?:magical\\s+)?core$", Pattern.CASE_INSENSITIVE); + + private GearName() {} + + /** The name a weapon built from these parts carries, or null when its core gives no material. */ + public static String expected(GearType type, Collection parts) { + ArchetypeDef archetype = ArchetypeRegistry.get(type); + if (archetype == null) { + return null; + } + String material = material(core(parts)); + Matcher codes = LEADING_CODES.matcher(material); + String colour = codes.find() ? codes.group() : ""; + String plain = material.substring(colour.length()).trim(); + if (plain.isEmpty()) { + return null; + } + return colour + archetype.getItemName().replace(MATERIAL, plain); + } + + /** True when the weapon's name no longer matches its core and archetype. */ + public static boolean isOutdated(ItemStack stack) { + String expected = expected(GearProvenance.archetypeOf(stack), GearProvenance.resolveParts(stack)); + return expected != null && !expected.equals(stored(stack)); + } + + /** The core's material, or its name without "Magical Core" when it sets none. */ + static String material(PartDef core) { + if (core == null) { + return ""; + } + if (!core.getMaterial().isEmpty()) { + return core.getMaterial(); + } + Matcher suffix = CORE_SUFFIX.matcher(core.getName()); + return suffix.find() ? core.getName().substring(0, suffix.start()) : ""; + } + + static PartDef core(Collection parts) { + if (parts == null) { + return null; + } + for (PartDef part : parts) { + if (part != null && PartSlots.CORE.equalsIgnoreCase(part.getPartType())) { + return part; + } + } + return null; + } + + /** Writes the name to the MMOItems data and its original, so MMOItems updates keep it. */ + public static void write(MMOItem mmo, String name) { + if (mmo == null || name == null) { + return; + } + NameData data = mmo.getData(ItemStats.NAME) instanceof NameData current ? current : new NameData(name); + data.setString(name); + mmo.replaceData(ItemStats.NAME, data); + StatHistory history = mmo.computeStatHistory(ItemStats.NAME); + if (history != null && history.getOriginalData() instanceof NameData original) { + original.setString(name); + mmo.setStatHistory(ItemStats.NAME, history); + } + } + + /** Records the name written on the weapon. */ + public static void stamp(ItemStack stack, String name) { + if (stack == null || name == null || !stack.hasItemMeta()) { + return; + } + ItemMeta meta = stack.getItemMeta(); + meta.getPersistentDataContainer().set(GearKeys.gearName(), PersistentDataType.STRING, name); + stack.setItemMeta(meta); + } + + static String stored(ItemStack stack) { + if (stack == null || !stack.hasItemMeta()) { + return null; + } + return stack.getItemMeta().getPersistentDataContainer().get(GearKeys.gearName(), PersistentDataType.STRING); + } +} diff --git a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java index eaaca55..b3d5e32 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java @@ -22,8 +22,8 @@ /** * Brings a crafted weapon back in line with the live gear config. * - * Socket colours and part stats are rewritten from the stamped parts. Runes already in - * the weapon are merged into the new layout; any that no longer fit are held by + * Socket colours, part stats and the name are rewritten from the stamped parts. Runes already + * in the weapon are merged into the new layout; any that no longer fit are held by * {@link GearBrokenMarker} instead of being dropped. */ public final class GearRefresher { @@ -55,7 +55,8 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { return broken; } - if (!force && !GearProvenance.isOutdated(stack) && !RuneRefresher.isOutdated(stack)) { + if (!force && !GearProvenance.isOutdated(stack) && !RuneRefresher.isOutdated(stack) + && !GearName.isOutdated(stack)) { int live = MajorityTierResolver.resolve(GearProvenance.resolveParts(stack)); if (live > 0 && GearProvenance.majorityOf(stack) != live) { ItemStack clone = stack.clone(); @@ -72,15 +73,17 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { } int band = WeaponRequirement.fromItem(stack).highestBand(); List colours = SocketLayout.colours(archetype, GearProvenance.resolveParts(stack), band); + String name = GearName.expected(type, GearProvenance.resolveParts(stack)); List orphaned = new ArrayList<>(); Map runeRevisions = new HashMap<>(); - ItemStack rebuilt = rewrite(stack, colours, orphaned, runeRevisions); + ItemStack rebuilt = rewrite(stack, colours, name, orphaned, runeRevisions); if (rebuilt == null) { return null; } copyGearPdc(stack, rebuilt); + GearName.stamp(rebuilt, name); RuneRefresher.stamp(rebuilt, runeRevisions); WeaponRequirement.fromItem(stack).persist(rebuilt); WeaponRift.copy(stack, rebuilt); @@ -107,8 +110,8 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { * this puts every gem it can back into a matching empty socket first, then brings * socketed runes up to their current template. */ - private static ItemStack rewrite(ItemStack stack, List colours, List orphaned, - Map runeRevisions) { + private static ItemStack rewrite(ItemStack stack, List colours, String name, + List orphaned, Map runeRevisions) { try { LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(stack)); List existing = new ArrayList<>(); @@ -128,6 +131,7 @@ private static ItemStack rewrite(ItemStack stack, List colours, List types, + String itemPath, + Map cost, + List partLimit, + Map stats, + Map sockets, + List lore, + String schemeId, + int schemeWeight, + boolean disabled, + String material) { this.id = id == null ? "" : id.trim().toLowerCase(Locale.ROOT); this.name = name == null || name.isBlank() ? this.id : name; + this.material = material == null ? "" : material.trim(); this.partType = partType == null ? "" : partType.trim().toLowerCase(Locale.ROOT); this.tier = tier < 1 ? 0 : tier; this.types = types == null || types.isEmpty() @@ -185,6 +207,11 @@ public boolean isDisabled() { return disabled; } + /** Coloured material a core puts in the weapon's name, such as "Iron". Empty when unset. */ + public String getMaterial() { + return material; + } + public int getRevision() { return revision; } @@ -194,8 +221,8 @@ public void setRevision(int revision) { } /** - * Canonical string of the fields that change gameplay. Name and lore are left out - * so cosmetic edits do not bump the revision and refresh every crafted weapon. + * Canonical string of the fields that change gameplay. Name, lore and material are left + * out so cosmetic edits do not bump the revision and refresh every crafted weapon. */ public String buildRevisionContent() { StringBuilder out = new StringBuilder(); diff --git a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java index 4872c26..6ed01af 100644 --- a/src/main/java/net/tfminecraft/magic/loader/GearLoader.java +++ b/src/main/java/net/tfminecraft/magic/loader/GearLoader.java @@ -117,7 +117,8 @@ private static boolean loadArchetypes(File file) { section.getString("icon", ""), section.getBoolean("melee", type == GearType.SWORD), section.getStringList("required"), - slots); + slots, + MagicText.format(section.getString("item-name", ""))); def.setRevision(Magic.getRevisionTracker().resolveArchetype( type.name(), RevisionTracker.sha256(def.buildRevisionContent()))); ArchetypeRegistry.register(def); @@ -388,7 +389,8 @@ private static boolean loadParts(File file) { lore, schemeId, schemeWeight, - section.getBoolean("disabled", false)); + section.getBoolean("disabled", false), + MagicText.format(section.getString("material", ""))); if (def.totalSocketCount() > 4) { Magic.plugin.getLogger().warning("[Magic] part '" + id + "' declares more than 4 sockets; extras are dropped at craft"); diff --git a/src/main/resources/gear/archetypes.yml b/src/main/resources/gear/archetypes.yml index c1603fb..c45f0d9 100644 --- a/src/main/resources/gear/archetypes.yml +++ b/src/main/resources/gear/archetypes.yml @@ -2,9 +2,12 @@ # slots: id -> TLibs colour suffix. Prefix comes from socket-colours.yml. # By default only the suffix is written, matching the school runes' gem-color. # icon: ItemRef path for picker/assembly GUIs (v.MATERIAL, m.TYPE.ID, ia.namespace:id). +# item-name: crafted weapon name. %material% is the core's material, and the whole name +# takes its colour. Defaults to "%material% ". staff: name: Staff + item-name: "%material% Mage Staff" template: m.mage_staffs.mage_custom_staff icon: m.mage_staffs.mage_custom_staff melee: false @@ -22,6 +25,7 @@ staff: wand: name: Wand + item-name: "%material% Mage Wand" template: m.mage_wands.mage_custom_wand icon: m.mage_wands.mage_custom_wand melee: false @@ -37,6 +41,7 @@ wand: sword: name: Sword + item-name: "%material% Mage Blade" template: m.mage_swords.mage_custom_sword icon: m.mage_swords.mage_custom_sword melee: true diff --git a/src/main/resources/gear/parts.yml b/src/main/resources/gear/parts.yml index 91761d9..46d1db4 100644 --- a/src/main/resources/gear/parts.yml +++ b/src/main/resources/gear/parts.yml @@ -3,9 +3,12 @@ # part-limit: extra part categories a core unlocks (mandatory once that core is chosen). # stats: MMOItems ids with doubles, same shape as cost — attack_damage(4). # model-scheme: scheme id, optional weight like iron(10). Looked up in model-schemes.yml. +# material: what a core names the weapon, colour included. Fills %material% in the +# archetype's item-name. Without it a core falls back to its name minus "Magical Core". iron_staff_core: name: "#d8d8d8Iron Magical Core" + material: "#d8d8d8Iron" part-type: core tier: 1 type: @@ -25,6 +28,7 @@ iron_staff_core: steel_staff_core: name: "#7f7d80Steel Magical Core" + material: "#7f7d80Steel" part-type: core tier: 2 type: @@ -45,6 +49,7 @@ steel_staff_core: abyssalite_staff_core: name: "#3b4e60Abyssalite Magical Core" + material: "#3b4e60Abyssalite" part-type: core tier: 3 type: @@ -66,6 +71,7 @@ abyssalite_staff_core: mythril_staff_core: name: "#9be1f3Mythril Magical Core" + material: "#9be1f3Mythril" part-type: core tier: 4 type: @@ -88,6 +94,7 @@ mythril_staff_core: iron_wand_core: name: "#d8d8d8Iron Magical Core" + material: "#d8d8d8Iron" part-type: core tier: 1 type: @@ -110,6 +117,7 @@ iron_wand_core: steel_wand_core: name: "#7f7d80Steel Magical Core" + material: "#7f7d80Steel" part-type: core tier: 2 type: @@ -132,6 +140,7 @@ steel_wand_core: abyssalite_wand_core: name: "#3b4e60Abyssalite Magical Core" + material: "#3b4e60Abyssalite" part-type: core tier: 3 type: @@ -155,6 +164,7 @@ abyssalite_wand_core: mythril_wand_core: name: "#9be1f3Mythril Magical Core" + material: "#9be1f3Mythril" part-type: core tier: 4 type: @@ -178,6 +188,7 @@ mythril_wand_core: iron_sword_core: name: "#d8d8d8Iron Magical Core" + material: "#d8d8d8Iron" part-type: core tier: 1 type: @@ -199,6 +210,7 @@ iron_sword_core: steel_sword_core: name: "#7f7d80Steel Magical Core" + material: "#7f7d80Steel" part-type: core tier: 2 type: @@ -220,6 +232,7 @@ steel_sword_core: abyssalite_sword_core: name: "#3b4e60Abyssalite Magical Core" + material: "#3b4e60Abyssalite" part-type: core tier: 3 type: @@ -242,6 +255,7 @@ abyssalite_sword_core: mythril_sword_core: name: "#9be1f3Mythril Magical Core" + material: "#9be1f3Mythril" part-type: core tier: 4 type: diff --git a/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java index 1cbca1f..ad7aff8 100644 --- a/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearItemCoverageTest.java @@ -149,20 +149,22 @@ void mmoRewritesUseBuiltItemOrSafelyKeepTemplateOnFailure() throws Exception { invoke( GearItemBuilder.class, "applyMmoData", - new Class[] {ItemStack.class, List.class, Collection.class}, + new Class[] {ItemStack.class, List.class, Collection.class, String.class}, null, List.of(), - List.of())); + List.of(), + null)); for (List colours : Arrays.asList(null, List.of())) assertSame( stack, invoke( GearItemBuilder.class, "applyMmoData", - new Class[] {ItemStack.class, List.class, Collection.class}, + new Class[] {ItemStack.class, List.class, Collection.class, String.class}, stack, colours, - List.of())); + List.of(), + null)); } } diff --git a/src/test/java/net/tfminecraft/magic/GearNameCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearNameCoverageTest.java new file mode 100644 index 0000000..830bf05 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/GearNameCoverageTest.java @@ -0,0 +1,295 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.api.item.NBTItem; +import java.util.*; +import net.Indyuce.mmoitems.ItemStats; +import net.Indyuce.mmoitems.api.item.build.ItemStackBuilder; +import net.Indyuce.mmoitems.api.item.mmoitem.LiveMMOItem; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.Indyuce.mmoitems.stat.data.StringData; +import net.Indyuce.mmoitems.stat.type.NameData; +import net.Indyuce.mmoitems.stat.type.StatHistory; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.util.ItemRef; +import org.bukkit.*; +import org.bukkit.inventory.*; +import org.bukkit.persistence.*; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; + +class GearNameCoverageTest extends GearMmoCoverageSupport { + static final String IRON = "§x§d§8§d§8§d§8Iron"; + static final String IRON_STAFF = IRON + " Mage Staff"; + + static PartDef core(String name, String material) { + return new PartDef( + "core", + name, + "core", + 2, + Set.of(GearType.STAFF), + "v.STICK", + Map.of(), + List.of(), + Map.of(), + Map.of("spell", 1), + List.of(), + "", + 1, + false, + material); + } + + static PartDef handle() { + return new PartDef( + "handle", "§dOak Magical Handle", "handle", 1, Set.of(GearType.STAFF), "v.STICK", + Map.of(), List.of(), Map.of(), Map.of(), List.of(), "", 1, false); + } + + static ArchetypeDef staff(String itemName) { + return new ArchetypeDef( + GearType.STAFF, + "Staff", + "v.STICK", + "v.STICK", + false, + List.of(), + Map.of("spell", "Spell"), + itemName); + } + + static String stored(ItemStack stack) { + return stack + .getItemMeta() + .getPersistentDataContainer() + .get(GearKeys.gearName(), PersistentDataType.STRING); + } + + ItemStack crafted(PartDef core) { + PartRegistry.register(core); + ArchetypeRegistry.register(staff("%material% Mage Staff")); + var item = item(); + GearProvenance.stamp(item, GearType.STAFF, List.of(core)); + return item; + } + + @Test + void definitionsDefaultTheirMaterialAndItemName() { + assertEquals("", core("Name", null).getMaterial()); + assertEquals(IRON, core("Name", " " + IRON + " ").getMaterial()); + assertEquals("", handle().getMaterial()); + assertEquals("%material% Staff", staff(null).getItemName()); + assertEquals("%material% Staff", staff(" ").getItemName()); + assertEquals("%material% Mage Staff", staff("%material% Mage Staff").getItemName()); + assertEquals( + "%material% Staff", + new ArchetypeDef(GearType.STAFF, "", "", "", false, List.of(), Map.of()).getItemName()); + } + + @Test + void materialComesFromTheCoreOrItsName() throws Exception { + var core = core("Name", IRON); + assertNull(invokeCore(null)); + assertNull(invokeCore(Arrays.asList(null, handle()))); + assertSame(core, invokeCore(List.of(handle(), core))); + assertEquals("", invokeMaterial(null)); + assertEquals(IRON, invokeMaterial(core)); + assertEquals(IRON, invokeMaterial(core(IRON + " Magical Core", ""))); + assertEquals("§dOak", invokeMaterial(core("§dOak core", ""))); + assertEquals("", invokeMaterial(core("Heart of the Forest", ""))); + } + + @Test + void expectedNameFillsTheArchetypeAndTakesTheMaterialColour() { + assertNull(GearName.expected(GearType.STAFF, List.of(core("Name", IRON)))); + ArchetypeRegistry.register(staff("%material% Mage Staff")); + assertEquals(IRON_STAFF, GearName.expected(GearType.STAFF, List.of(core("Name", IRON)))); + assertEquals("Iron Mage Staff", GearName.expected(GearType.STAFF, List.of(core("Name", "Iron")))); + assertNull(GearName.expected(GearType.STAFF, List.of(core("Name", "§x§d§8§d§8§d§8 ")))); + assertNull(GearName.expected(GearType.STAFF, List.of(handle()))); + assertNull(GearName.expected(GearType.STAFF, null)); + ArchetypeRegistry.register(staff("§lRelic")); + assertEquals("§x§d§8§d§8§d§8§lRelic", GearName.expected(GearType.STAFF, List.of(core("Name", IRON)))); + } + + @Test + void outdatedOnlyWhenTheCoreNamesSomethingElse() { + var item = crafted(core("Name", IRON)); + assertTrue(GearName.isOutdated(item)); + GearName.stamp(item, "§7Old Mage Staff"); + assertTrue(GearName.isOutdated(item)); + GearName.stamp(item, IRON_STAFF); + assertFalse(GearName.isOutdated(item)); + PartRegistry.clear(); + var plain = crafted(core("Name", "")); + assertFalse(GearName.isOutdated(plain)); + assertFalse(GearName.isOutdated(null)); + } + + @Test + void stampAndStoredSkipMissingStacksNamesAndMeta() throws Exception { + GearName.stamp(null, IRON_STAFF); + var item = item(); + GearName.stamp(item, null); + assertNull(stored(item)); + var air = new ItemStack(Material.AIR); + GearName.stamp(air, IRON_STAFF); + assertFalse(air.hasItemMeta()); + assertNull(invoke(GearName.class, "stored", new Class[] {ItemStack.class}, (Object) null)); + assertNull(invoke(GearName.class, "stored", new Class[] {ItemStack.class}, air)); + GearName.stamp(item, IRON_STAFF); + assertEquals(IRON_STAFF, invoke(GearName.class, "stored", new Class[] {ItemStack.class}, item)); + } + + @Test + void writeRenamesTheItemAndItsOriginalSoMmoItemsUpdatesKeepIt() { + var untouched = mock(MMOItem.class); + GearName.write(null, IRON_STAFF); + GearName.write(untouched, null); + verifyNoInteractions(untouched); + + var mmo = mock(MMOItem.class); + var current = new NameData("Mage Staff"); + var original = new NameData("Mage Staff"); + var history = mock(StatHistory.class); + when(history.getOriginalData()).thenReturn(original); + when(mmo.getData(ItemStats.NAME)).thenReturn(current); + when(mmo.computeStatHistory(ItemStats.NAME)).thenReturn(history); + GearName.write(mmo, IRON_STAFF); + assertEquals(IRON_STAFF, current.getString()); + assertEquals(IRON_STAFF, original.getString()); + verify(mmo).replaceData(ItemStats.NAME, current); + verify(mmo).setStatHistory(ItemStats.NAME, history); + + var bare = mock(MMOItem.class); + when(bare.getData(ItemStats.NAME)).thenReturn(new StringData("plain")); + var otherHistory = mock(StatHistory.class); + when(otherHistory.getOriginalData()).thenReturn(new StringData("plain")); + when(bare.computeStatHistory(ItemStats.NAME)).thenReturn(otherHistory); + GearName.write(bare, IRON_STAFF); + verify(bare) + .replaceData( + eq(ItemStats.NAME), + argThat(d -> d instanceof NameData n && IRON_STAFF.equals(n.getString()))); + verify(bare, never()).setStatHistory(any(), any()); + + var fresh = mock(MMOItem.class); + GearName.write(fresh, IRON_STAFF); + verify(fresh).replaceData(eq(ItemStats.NAME), any(NameData.class)); + verify(fresh, never()).setStatHistory(any(), any()); + } + + @Test + void craftingNamesTheWeaponAndThePreviewShowsIt() { + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var core = core("Name", IRON); + PartRegistry.register(core); + ArchetypeRegistry.register(staff("%material% Mage Staff")); + var built = item(); + try (var refs = mockStatic(ItemRef.class); + var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var models = mockStatic(GearModelResolver.class); + var lore = mockStatic(WeaponLore.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + when(builder.build()).thenReturn(built); + })) { + refs.when(() -> ItemRef.build("v.STICK")).thenAnswer(i -> item()); + models + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearItemBuilder.prepare(GearType.STAFF, List.of(core)); + assertSame(built, result); + assertEquals(IRON_STAFF, stored(result)); + verify(items.constructed().getFirst()) + .replaceData( + eq(ItemStats.NAME), + argThat(d -> d instanceof NameData n && IRON_STAFF.equals(n.getString()))); + var preview = GearItemBuilder.preview(GearType.STAFF, List.of(core)); + assertEquals(IRON_STAFF, preview.getItemMeta().getDisplayName()); + } + } + + @Test + void socketRewritesRenameTheWeapon() { + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var item = crafted(core("Name", IRON)); + var built = item(); + try (var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var models = mockStatic(GearModelResolver.class); + var skin = mockStatic(GearSkin.class, i -> i.getArgument(1)); + var lore = mockStatic(WeaponLore.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + when(builder.build()).thenReturn(built); + })) { + models + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearItemBuilder.rewriteSockets(item, 1); + assertSame(built, result); + assertEquals(IRON_STAFF, stored(result)); + assertTrue(GearProvenance.socketsLocked(result)); + } + } + + @Test + void refreshRenamesWeaponsCraftedBeforeTheirCoreNamedThemOnce() { + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var item = crafted(core("Name", IRON)); + var built = item(); + try (var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var models = mockStatic(GearModelResolver.class); + var skin = mockStatic(GearSkin.class, i -> i.getArgument(1)); + var lore = mockStatic(WeaponLore.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + when(builder.build()).thenReturn(built); + })) { + models + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + var result = GearRefresher.refreshIfOutdated(item, null); + assertSame(built, result); + assertEquals(IRON_STAFF, stored(result)); + verify(items.constructed().getFirst()) + .replaceData( + eq(ItemStats.NAME), + argThat(d -> d instanceof NameData n && IRON_STAFF.equals(n.getString()))); + assertNull(GearRefresher.refreshIfOutdated(result, null)); + assertEquals(1, items.constructed().size()); + } + } + + static Object invokeCore(Collection parts) throws Exception { + return invoke(GearName.class, "core", new Class[] {Collection.class}, parts); + } + + static Object invokeMaterial(PartDef core) throws Exception { + return invoke(GearName.class, "material", new Class[] {PartDef.class}, core); + } +}