From d77d1de9b561fe0b3554939d7cc7bcb86594f0ac Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:07:14 +0200 Subject: [PATCH] Name mage weapons after their core's material Crafted mage weapons kept the template's plain white name ("Mage Staff", "Mage Wand", "Mage Blade"), while AdvancedCrafting weapons and armour are named and coloured after their majority material. Magic never wrote the MMOItems name. The core now names the weapon, since it already picks the model. A core's optional `material:` (e.g. "#d8d8d8Iron") fills `%material%` in the archetype's new `item-name:` (e.g. "%material% Mage Staff"), and the whole name takes the material's colour. A core without `material:` falls back to its name minus "Magical Core"; an archetype without `item-name:` uses "%material% ". The name is written to the MMOItems data and its original, as AdvancedCrafting does, so MMOItems updates keep it. Crafting, socket rewrites, refreshes and the station preview all use it. The name Magic wrote is kept in `magic:gear_name`, so the join/chest-open refresh renames weapons crafted before this once, keeping their runes. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/magic/gear/ArchetypeDef.java | 23 +- .../magic/gear/GearItemBuilder.java | 24 +- .../net/tfminecraft/magic/gear/GearKeys.java | 5 + .../net/tfminecraft/magic/gear/GearName.java | 107 +++++++ .../tfminecraft/magic/gear/GearRefresher.java | 16 +- .../net/tfminecraft/magic/gear/PartDef.java | 31 +- .../tfminecraft/magic/loader/GearLoader.java | 6 +- src/main/resources/gear/archetypes.yml | 5 + src/main/resources/gear/parts.yml | 14 + .../magic/GearItemCoverageTest.java | 10 +- .../magic/GearNameCoverageTest.java | 295 ++++++++++++++++++ 11 files changed, 517 insertions(+), 19 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/gear/GearName.java create mode 100644 src/test/java/net/tfminecraft/magic/GearNameCoverageTest.java 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); + } +}