From 7629c60313bd14c204cd896090ea014a0017ff71 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:16:03 +0200 Subject: [PATCH 1/2] Refresh mage weapons on join and chest open; socketed runes follow their template Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/magic/gear/GearKeys.java | 5 + .../tfminecraft/magic/gear/GearRefresher.java | 16 +- .../tfminecraft/magic/gear/RuneRefresher.java | 207 ++++++++++++++ .../magic/listener/GearRefreshListener.java | 46 ++- .../magic/GearRefresherCoverageTest.java | 33 +++ .../net/tfminecraft/magic/ListenerTest.java | 73 +++++ .../magic/gear/RuneRefresherTest.java | 266 ++++++++++++++++++ 7 files changed, 641 insertions(+), 5 deletions(-) create mode 100644 src/main/java/net/tfminecraft/magic/gear/RuneRefresher.java create mode 100644 src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java diff --git a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java index 71bc28c..90f715d 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearKeys.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearKeys.java @@ -61,6 +61,11 @@ public static NamespacedKey partPick() { return new NamespacedKey(Magic.plugin, "gear_part_id"); } + /** Template revision of each socketed rune, by its MMOItems gem history id. */ + public static NamespacedKey runeRevisions() { + return new NamespacedKey(Magic.plugin, "gear_rune_revisions"); + } + /** Marks the ItemDisplay that shows the weapon resting on a gear station. */ public static NamespacedKey stationDisplay() { return new NamespacedKey(Magic.plugin, "gear_station_display"); diff --git a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java index 6c235a8..7ba3765 100644 --- a/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java +++ b/src/main/java/net/tfminecraft/magic/gear/GearRefresher.java @@ -1,7 +1,10 @@ package net.tfminecraft.magic.gear; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.UUID; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -52,7 +55,7 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { return broken; } - if (!force && !GearProvenance.isOutdated(stack)) { + if (!force && !GearProvenance.isOutdated(stack) && !RuneRefresher.isOutdated(stack)) { int live = MajorityTierResolver.resolve(GearProvenance.resolveParts(stack)); if (live > 0 && GearProvenance.majorityOf(stack) != live) { ItemStack clone = stack.clone(); @@ -71,12 +74,14 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { List colours = SocketLayout.colours(archetype, GearProvenance.resolveParts(stack), band); List orphaned = new ArrayList<>(); - ItemStack rebuilt = rewrite(stack, colours, orphaned); + Map runeRevisions = new HashMap<>(); + ItemStack rebuilt = rewrite(stack, colours, orphaned, runeRevisions); if (rebuilt == null) { return null; } copyGearPdc(stack, rebuilt); + RuneRefresher.stamp(rebuilt, runeRevisions); WeaponRequirement.fromItem(stack).persist(rebuilt); WeaponRift.copy(stack, rebuilt); GearProvenance.syncRevisions(rebuilt); @@ -99,9 +104,11 @@ public static ItemStack refresh(ItemStack stack, Player holder, boolean force) { /** * Writes the target socket colours while carrying existing runes across. Unlike * {@code GearItemBuilder} craft, which builds fresh socket data at craft time, - * this puts every gem it can back into a matching empty socket first. + * 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) { + private static ItemStack rewrite(ItemStack stack, List colours, List orphaned, + Map runeRevisions) { try { LiveMMOItem mmo = new LiveMMOItem(NBTItem.get(stack)); List existing = new ArrayList<>(); @@ -121,6 +128,7 @@ private static ItemStack rewrite(ItemStack stack, List colours, List stamped = stamps(stack); + for (Socketed gem : socketed(stack)) { + int live = liveRevision(gem); + if (live >= 0 && !Integer.valueOf(live).equals(stamped.get(gem.uuid()))) { + return true; + } + } + return false; + } + + /** + * Brings every outdated rune in {@code mmo} up to its template. {@code stack} is the + * weapon {@code mmo} was read from. Returns the revisions to stamp on the rebuilt weapon. + */ + public static Map refresh(MMOItem mmo, ItemStack stack) { + Map stamped = stamps(stack); + Map revisions = new LinkedHashMap<>(); + for (Socketed gem : socketed(stack)) { + int live = liveRevision(gem); + if (live < 0) { + continue; + } + if (!Integer.valueOf(live).equals(stamped.get(gem.uuid()))) { + replace(mmo, gem); + } + revisions.put(gem.uuid(), live); + } + return revisions; + } + + public static void stamp(ItemStack stack, Map revisions) { + if (stack == null || !stack.hasItemMeta()) { + return; + } + ItemMeta meta = stack.getItemMeta(); + if (revisions.isEmpty()) { + meta.getPersistentDataContainer().remove(GearKeys.runeRevisions()); + } else { + StringJoiner raw = new StringJoiner(","); + revisions.forEach((uuid, revision) -> raw.add(uuid + "@" + revision)); + meta.getPersistentDataContainer().set(GearKeys.runeRevisions(), PersistentDataType.STRING, raw.toString()); + } + stack.setItemMeta(meta); + } + + static Map stamps(ItemStack stack) { + Map stamps = new HashMap<>(); + if (stack == null || !stack.hasItemMeta()) { + return stamps; + } + String raw = stack.getItemMeta().getPersistentDataContainer() + .get(GearKeys.runeRevisions(), PersistentDataType.STRING); + if (raw == null || raw.isBlank()) { + return stamps; + } + for (String entry : raw.split(",")) { + String[] split = entry.split("@", 2); + try { + stamps.put(UUID.fromString(split[0].trim()), Integer.parseInt(split[1].trim())); + } catch (IllegalArgumentException | ArrayIndexOutOfBoundsException ignored) { + // A damaged entry only means that rune is refreshed once more. + } + } + return stamps; + } + + /** Socketed gems, read from MMOItems' socket NBT without building the whole item. */ + static List socketed(ItemStack stack) { + List gems = new ArrayList<>(); + if (stack == null || stack.getType().isAir()) { + return gems; + } + try { + NBTItem nbt = NBTItem.get(stack); + String raw = nbt == null ? null : nbt.getString(ItemStats.GEM_SOCKETS.getNBTPath()); + if (raw == null || raw.isBlank()) { + return gems; + } + JsonObject root = JsonParser.parseString(raw).getAsJsonObject(); + if (!root.has("Gemstones")) { + return gems; + } + for (JsonElement element : root.getAsJsonArray("Gemstones")) { + JsonObject gem = element.getAsJsonObject(); + if (gem.has("History") && gem.has("Type") && gem.has("Id")) { + gems.add(new Socketed(UUID.fromString(gem.get("History").getAsString()), + gem.get("Type").getAsString(), gem.get("Id").getAsString())); + } + } + } catch (RuntimeException ex) { + return new ArrayList<>(); + } + return gems; + } + + /** The rune's current template revision, or -1 when MMOItems no longer has it. */ + static int liveRevision(Socketed gem) { + Type type = MMOItems.plugin.getTypes().get(gem.type()); + if (type == null) { + return -1; + } + MMOItemTemplate template = MMOItems.plugin.getTemplates().getTemplate(type, gem.id()); + return template == null ? -1 : template.getRevisionId(); + } + + /** Same steps as MMOItems socketing a gem, but over the gem's existing history id. */ + @SuppressWarnings({"rawtypes", "unchecked"}) + static void replace(MMOItem mmo, Socketed gem) { + MMOItem fresh = MMOItems.plugin.getMMOItem(MMOItems.plugin.getTypes().get(gem.type()), gem.id()); + if (fresh == null) { + Magic.plugin.getLogger().warning("[Magic] Rune " + gem.type() + "." + gem.id() + " could not be rebuilt; kept as socketed."); + return; + } + AbilityListData oldAbilities = null; + for (StatHistory hist : new ArrayList<>(mmo.getStatHistories())) { + StatData old = hist.getGemstoneData(gem.uuid()); + if (old == null) { + continue; + } + if (old instanceof AbilityListData list) { + oldAbilities = list; + } + hist.removeGemData(gem.uuid()); + mmo.setData(hist.getItemStat(), hist.recalculate(mmo.getUpgradeLevel())); + } + for (ItemStat stat : fresh.getStats()) { + StatData data = fresh.getData(stat); + if (stat instanceof GemStoneStat || !(data instanceof Mergeable)) { + continue; + } + if (data instanceof AbilityListData list) { + data = keepTriggers(list, oldAbilities); + } + mmo.mergeData(stat, data, gem.uuid()); + } + } + + /** The template's abilities with the trigger each one had in the weapon, by position. */ + static AbilityListData keepTriggers(AbilityListData fresh, AbilityListData old) { + if (old == null) { + return fresh; + } + List was = old.getAbilities(); + List copies = new ArrayList<>(); + int index = 0; + for (AbilityData ability : fresh.getAbilities()) { + if (index >= was.size()) { + copies.add(ability); + } else { + AbilityData copy = new AbilityData(ability.getAbility(), was.get(index).getTrigger()); + for (String modifier : ability.getModifiers()) { + copy.setModifier(modifier, ability.getParameter(modifier)); + } + copies.add(copy); + } + index++; + } + return new AbilityListData(copies); + } +} diff --git a/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java b/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java index 41e52ed..26784cf 100644 --- a/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java @@ -1,14 +1,20 @@ package net.tfminecraft.magic.listener; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.Entity; import org.bukkit.entity.Item; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.event.player.PlayerItemHeldEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.BlockInventoryHolder; import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; import net.tfminecraft.magic.Magic; @@ -17,7 +23,9 @@ /** * Lazy refresh. A crafted weapon catches up with the gear config the next time the * player touches it, so a reload never has to walk every inventory on the server. - * Runs next tick because the slot contents have not settled when these events fire. + * Joining and opening a chest also check every slot, so after a restart weapons catch + * up without anyone touching them. Runs next tick because the slot contents have not + * settled when these events fire. */ public final class GearRefreshListener implements Listener { @@ -78,6 +86,42 @@ public void onItemHeld(PlayerItemHeldEvent event) { }); } + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + later(() -> { + if (!player.isOnline()) { + return; + } + sweep(player.getInventory(), player); + sweep(player.getEnderChest(), player); + }); + } + + /** Chests, barrels and storage entities only; plugin menus are left alone. */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onOpen(InventoryOpenEvent event) { + Inventory inventory = event.getInventory(); + if (!isWorldStorage(inventory.getHolder(false)) || !(event.getPlayer() instanceof Player player)) { + return; + } + later(() -> sweep(inventory, player)); + } + + public static boolean isWorldStorage(InventoryHolder holder) { + return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest || holder instanceof Entity; + } + + private static void sweep(Inventory inventory, Player player) { + ItemStack[] contents = inventory.getContents(); + for (int slot = 0; slot < contents.length; slot++) { + ItemStack rebuilt = GearRefresher.refreshIfOutdated(contents[slot], player); + if (rebuilt != null) { + inventory.setItem(slot, rebuilt); + } + } + } + private static void later(Runnable task) { if (Magic.plugin == null) { return; diff --git a/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java b/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java index 9e3f195..314bb9a 100644 --- a/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java +++ b/src/test/java/net/tfminecraft/magic/GearRefresherCoverageTest.java @@ -133,6 +133,39 @@ void rebuildPreservesGearPdcAmountsAndExistingGems() { } } + @Test + void outdatedRunesRebuildCurrentGearAndStampTheirRevisions() { + var item = managed(); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + var built = item(); + var revisions = Map.of(UUID.randomUUID(), 3); + try (var nbt = mockStatic(NBTItem.class); + var stats = mockStatic(GearStatApplicator.class); + var model = mockStatic(GearModelResolver.class); + var lore = mockStatic(WeaponLore.class); + var runes = mockStatic(RuneRefresher.class); + var items = + mockConstruction( + LiveMMOItem.class, + (m, c) -> { + var builder = mock(ItemStackBuilder.class); + when(m.newBuilder()).thenReturn(builder); + when(builder.build()).thenReturn(built); + })) { + model + .when(() -> GearModelResolver.apply(any(), any(), any())) + .thenAnswer(i -> i.getArgument(0)); + lore.when(() -> WeaponLore.updateItem(any())).thenAnswer(i -> i.getArgument(0)); + runes.when(() -> RuneRefresher.isOutdated(item)).thenReturn(false); + assertNull(GearRefresher.refreshIfOutdated(item, null)); + runes.when(() -> RuneRefresher.isOutdated(item)).thenReturn(true); + runes.when(() -> RuneRefresher.refresh(any(), eq(item))).thenReturn(revisions); + assertSame(built, GearRefresher.refreshIfOutdated(item, null)); + runes.verify(() -> RuneRefresher.stamp(built, revisions)); + } + } + @Test void incompatibleGemsAreHeldAndFailuresNeverReplaceOriginals() { MockBukkit.createMockPlugin("MMOItems"); diff --git a/src/test/java/net/tfminecraft/magic/ListenerTest.java b/src/test/java/net/tfminecraft/magic/ListenerTest.java index 20f4fc9..c64c644 100644 --- a/src/test/java/net/tfminecraft/magic/ListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/ListenerTest.java @@ -12,6 +12,8 @@ import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; import net.tfminecraft.rpcharacters.objects.RPCharacter; import org.bukkit.Material; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.minecart.StorageMinecart; import org.bukkit.entity.*; import org.bukkit.event.entity.EntityPickupItemEvent; import org.bukkit.event.inventory.*; @@ -180,4 +182,75 @@ void roleplayCharacterEventsSavePreviousThenActivateAndCloseOldGui() { verify(profile).deactivate(player); verify(Magic.plugin).clearSpellModifiers(player); } + + @Test + void joinSweepRefreshesInventoryOffhandAndEnderChestNextTick() { + var listener = new GearRefreshListener(); + var player = server.addPlayer(); + var old = new ItemStack(Material.STICK); + var rebuilt = new ItemStack(Material.BLAZE_ROD); + player.getInventory().setItem(4, old); + player.getInventory().setItemInOffHand(old); + player.getInventory().setItem(5, new ItemStack(Material.DIRT)); + player.getEnderChest().setItem(1, old); + try (var refresh = mockStatic(GearRefresher.class)) { + refresh.when(() -> GearRefresher.refreshIfOutdated(old, player)).thenReturn(rebuilt); + var join = mock(PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(player); + listener.onJoin(join); + assertEquals(old, player.getInventory().getItem(4)); + server.getScheduler().performOneTick(); + assertEquals(rebuilt, player.getInventory().getItem(4)); + assertEquals(rebuilt, player.getInventory().getItemInOffHand()); + assertEquals(Material.DIRT, player.getInventory().getItem(5).getType()); + assertEquals(rebuilt, player.getEnderChest().getItem(1)); + Player gone = mock(Player.class); + when(gone.isOnline()).thenReturn(false); + when(join.getPlayer()).thenReturn(gone); + listener.onJoin(join); + server.getScheduler().performOneTick(); + verify(gone, never()).getInventory(); + } + } + + @Test + void openSweepOnlyTouchesWorldStorageOpenedByPlayers() { + var listener = new GearRefreshListener(); + var player = server.addPlayer(); + var old = new ItemStack(Material.STICK); + var rebuilt = new ItemStack(Material.BLAZE_ROD); + try (var refresh = mockStatic(GearRefresher.class)) { + refresh.when(() -> GearRefresher.refreshIfOutdated(old, player)).thenReturn(rebuilt); + Inventory menu = mock(Inventory.class); + var open = mock(InventoryOpenEvent.class); + when(open.getInventory()).thenReturn(menu); + when(open.getPlayer()).thenReturn(player); + listener.onOpen(open); + server.getScheduler().performOneTick(); + verify(menu, never()).getContents(); + Inventory chest = mock(Inventory.class); + when(chest.getHolder(false)).thenReturn(mock(BlockInventoryHolder.class)); + when(chest.getContents()).thenReturn(new ItemStack[] {null, old}); + when(open.getInventory()).thenReturn(chest); + when(open.getPlayer()).thenReturn(mock(HumanEntity.class)); + listener.onOpen(open); + server.getScheduler().performOneTick(); + verify(chest, never()).getContents(); + when(open.getPlayer()).thenReturn(player); + listener.onOpen(open); + verify(chest, never()).setItem(anyInt(), any()); + server.getScheduler().performOneTick(); + verify(chest).setItem(1, rebuilt); + verify(chest, never()).setItem(eq(0), any()); + } + } + + @Test + void worldStorageCoversBlocksDoubleChestsAndEntitiesButNotMenus() { + assertTrue(GearRefreshListener.isWorldStorage(mock(BlockInventoryHolder.class))); + assertTrue(GearRefreshListener.isWorldStorage(mock(DoubleChest.class))); + assertTrue(GearRefreshListener.isWorldStorage(mock(StorageMinecart.class))); + assertFalse(GearRefreshListener.isWorldStorage(mock(InventoryHolder.class))); + assertFalse(GearRefreshListener.isWorldStorage(null)); + } } diff --git a/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java b/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java new file mode 100644 index 0000000..1e12165 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java @@ -0,0 +1,266 @@ +package net.tfminecraft.magic.gear; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.lumine.mythic.lib.MythicLib; +import io.lumine.mythic.lib.api.item.NBTItem; +import io.lumine.mythic.lib.skill.trigger.TriggerType; +import java.util.*; +import java.util.logging.Logger; +import net.Indyuce.mmoitems.MMOItems; +import net.Indyuce.mmoitems.api.Type; +import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; +import net.Indyuce.mmoitems.api.item.template.MMOItemTemplate; +import net.Indyuce.mmoitems.skill.RegisteredSkill; +import net.Indyuce.mmoitems.stat.data.AbilityData; +import net.Indyuce.mmoitems.stat.data.AbilityListData; +import net.Indyuce.mmoitems.stat.data.DoubleData; +import net.Indyuce.mmoitems.stat.data.type.StatData; +import net.Indyuce.mmoitems.stat.type.GemStoneStat; +import net.Indyuce.mmoitems.stat.type.ItemStat; +import net.Indyuce.mmoitems.stat.type.StatHistory; +import net.tfminecraft.magic.Magic; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.MockedStatic; + +class RuneRefresherTest { + static final UUID A = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + static final UUID B = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + static final UUID C = UUID.fromString("00000000-0000-0000-0000-00000000000c"); + MMOItems oldMmo; + MythicLib oldLib; + Logger logger; + + @BeforeEach + void setup() { + MockBukkit.mock(); + Magic.plugin = mock(Magic.class, RETURNS_DEEP_STUBS); + when(Magic.plugin.getName()).thenReturn("Magic"); + when(Magic.plugin.namespace()).thenReturn("magic"); + logger = mock(Logger.class); + when(Magic.plugin.getLogger()).thenReturn(logger); + oldMmo = MMOItems.plugin; + oldLib = MythicLib.plugin; + MMOItems.plugin = mock(MMOItems.class, RETURNS_DEEP_STUBS); + when(MMOItems.plugin.namespace()).thenReturn("mmoitems"); + when(MMOItems.plugin.getTypes().get(anyString())).thenReturn(null); + MythicLib.plugin = mock(MythicLib.class, RETURNS_DEEP_STUBS); + when(MythicLib.plugin.namespace()).thenReturn("mythiclib"); + } + + @AfterEach + void cleanup() { + TYPES.clear(); + MMOItems.plugin = oldMmo; + MythicLib.plugin = oldLib; + Magic.plugin = null; + MockBukkit.unmock(); + } + + static ItemStack weapon() { + var item = new ItemStack(Material.STICK); + var meta = item.getItemMeta(); + meta.setDisplayName("Gear"); + item.setItemMeta(meta); + return item; + } + + static void stampRaw(ItemStack item, String raw) { + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(GearKeys.runeRevisions(), PersistentDataType.STRING, raw); + item.setItemMeta(meta); + } + + static String gem(UUID uuid, String type, String id) { + return "{\"Name\":\"Rune\",\"History\":\"" + uuid + "\",\"Type\":\"" + type + "\",\"Id\":\"" + id + + "\",\"Color\":\"Minor Rune\"}"; + } + + static String sockets(String... gems) { + return "{\"EmptySlots\":[],\"Gemstones\":[" + String.join(",", gems) + "]}"; + } + + static MockedStatic nbt(String raw) { + MockedStatic nbt = mockStatic(NBTItem.class); + NBTItem item = mock(NBTItem.class); + when(item.getString(anyString())).thenReturn(raw); + nbt.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(item); + return nbt; + } + + static final Map TYPES = new HashMap<>(); + + static Type template(String typeId, String id, Integer revision) { + Type type = TYPES.computeIfAbsent(typeId, k -> mock(Type.class)); + when(MMOItems.plugin.getTypes().get(typeId)).thenReturn(type); + MMOItemTemplate template = null; + if (revision != null) { + template = mock(MMOItemTemplate.class); + when(template.getRevisionId()).thenReturn(revision); + } + when(MMOItems.plugin.getTemplates().getTemplate(type, id)).thenReturn(template); + return type; + } + + @Test + void stampsRoundTripAndSkipDamagedEntries() { + assertTrue(RuneRefresher.stamps(null).isEmpty()); + ItemStack bare = mock(ItemStack.class); + when(bare.hasItemMeta()).thenReturn(false); + assertTrue(RuneRefresher.stamps(bare).isEmpty()); + var item = weapon(); + assertTrue(RuneRefresher.stamps(item).isEmpty()); + stampRaw(item, " "); + assertTrue(RuneRefresher.stamps(item).isEmpty()); + RuneRefresher.stamp(item, new LinkedHashMap<>(Map.of(A, 3))); + assertEquals(Map.of(A, 3), RuneRefresher.stamps(item)); + stampRaw(item, A + "@3,broken,not-a-uuid@2," + B + "@x"); + assertEquals(Map.of(A, 3), RuneRefresher.stamps(item)); + RuneRefresher.stamp(item, Map.of()); + assertFalse(item.getItemMeta().getPersistentDataContainer().has(GearKeys.runeRevisions())); + RuneRefresher.stamp(null, Map.of(A, 1)); + RuneRefresher.stamp(bare, Map.of(A, 1)); + verify(bare, never()).getItemMeta(); + } + + @Test + void socketedReadsGemsAndIgnoresBrokenData() { + assertTrue(RuneRefresher.socketed(null).isEmpty()); + assertTrue(RuneRefresher.socketed(new ItemStack(Material.AIR)).isEmpty()); + try (var nbt = mockStatic(NBTItem.class)) { + nbt.when(() -> NBTItem.get(any(ItemStack.class))).thenReturn(null); + assertTrue(RuneRefresher.socketed(weapon()).isEmpty()); + } + for (String raw : Arrays.asList(null, " ", "{}", "not json", "[1]", sockets(gem(null, "T", "I")))) { + try (var nbt = nbt(raw)) { + assertTrue(RuneRefresher.socketed(weapon()).isEmpty(), String.valueOf(raw)); + } + } + String raw = + sockets( + gem(A, "CERRITH_RUNES", "RUNE_OF_HEALING_ORB"), + "{\"History\":\"" + B + "\",\"Type\":\"T\"}", + "{\"History\":\"" + B + "\",\"Id\":\"I\"}", + "{\"Type\":\"T\",\"Id\":\"I\"}"); + try (var nbt = nbt(raw)) { + var gems = RuneRefresher.socketed(weapon()); + assertEquals(List.of(new RuneRefresher.Socketed(A, "CERRITH_RUNES", "RUNE_OF_HEALING_ORB")), gems); + } + } + + @Test + void outdatedComparesTheLiveTemplateRevisionWithTheStamp() { + template("KNOWN", "RUNE", 3); + template("NOTEMPLATE", "RUNE", null); + var item = weapon(); + try (var nbt = nbt(sockets(gem(B, "UNKNOWN", "RUNE"), gem(C, "NOTEMPLATE", "RUNE")))) { + assertFalse(RuneRefresher.isOutdated(item)); + } + try (var nbt = nbt(sockets(gem(A, "KNOWN", "RUNE")))) { + assertTrue(RuneRefresher.isOutdated(item)); + RuneRefresher.stamp(item, Map.of(A, 2)); + assertTrue(RuneRefresher.isOutdated(item)); + RuneRefresher.stamp(item, Map.of(A, 3)); + assertFalse(RuneRefresher.isOutdated(item)); + } + } + + @Test + void refreshReplacesOnlyOutdatedRunesAndReportsTheirRevisions() { + template("KNOWN", "CURRENT", 3); + template("KNOWN", "STALE", 5); + var item = weapon(); + RuneRefresher.stamp(item, Map.of(A, 3, B, 4)); + when(MMOItems.plugin.getMMOItem(any(), anyString())).thenReturn(null); + MMOItem mmo = mock(MMOItem.class); + try (var nbt = + nbt(sockets(gem(A, "KNOWN", "CURRENT"), gem(B, "KNOWN", "STALE"), gem(C, "GONE", "X")))) { + assertEquals(Map.of(A, 3, B, 5), RuneRefresher.refresh(mmo, item)); + } + verify(MMOItems.plugin, times(1)).getMMOItem(any(), eq("STALE")); + verify(MMOItems.plugin, never()).getMMOItem(any(), eq("CURRENT")); + verify(logger).warning(contains("KNOWN.STALE")); + verifyNoInteractions(mmo); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void replaceMovesGemDataToTheTemplateAndKeepsTriggers() { + Type type = template("KNOWN", "STALE", 5); + MMOItem mmo = mock(MMOItem.class); + when(mmo.getUpgradeLevel()).thenReturn(2); + AbilityData oldAbility = mock(AbilityData.class); + when(oldAbility.getTrigger()).thenReturn(TriggerType.LEFT_CLICK); + ItemStat abilityStat = mock(ItemStat.class); + ItemStat manaStat = mock(ItemStat.class); + StatHistory abilityHist = mock(StatHistory.class); + StatHistory manaHist = mock(StatHistory.class); + StatHistory otherHist = mock(StatHistory.class); + when(abilityHist.getGemstoneData(B)).thenReturn(new AbilityListData(List.of(oldAbility))); + when(manaHist.getGemstoneData(B)).thenReturn(new DoubleData(1)); + when(abilityHist.getItemStat()).thenReturn(abilityStat); + when(manaHist.getItemStat()).thenReturn(manaStat); + StatData recalculated = mock(StatData.class); + when(abilityHist.recalculate(2)).thenReturn(recalculated); + when(manaHist.recalculate(2)).thenReturn(recalculated); + when(mmo.getStatHistories()).thenReturn(new ArrayList<>(List.of(abilityHist, manaHist, otherHist))); + + MMOItem fresh = mock(MMOItem.class); + ItemStat gemOnly = mock(ItemStat.class, withSettings().extraInterfaces(GemStoneStat.class)); + ItemStat plain = mock(ItemStat.class); + DoubleData mana = new DoubleData(2); + AbilityData freshAbility = mock(AbilityData.class); + when(freshAbility.getAbility()).thenReturn(mock(RegisteredSkill.class)); + when(freshAbility.getModifiers()).thenReturn(Set.of("heal")); + when(freshAbility.getParameter("heal")).thenReturn(13.0); + when(fresh.getStats()) + .thenReturn(new LinkedHashSet<>(List.of(gemOnly, plain, manaStat, abilityStat))); + when(fresh.getData(gemOnly)).thenReturn(new DoubleData(9)); + when(fresh.getData(plain)).thenReturn(mock(StatData.class)); + when(fresh.getData(manaStat)).thenReturn(mana); + when(fresh.getData(abilityStat)).thenReturn(new AbilityListData(List.of(freshAbility))); + when(MMOItems.plugin.getMMOItem(type, "STALE")).thenReturn(fresh); + + try (var abilities = mockConstruction(AbilityData.class)) { + RuneRefresher.replace(mmo, new RuneRefresher.Socketed(B, "KNOWN", "STALE")); + verify(abilityHist).removeGemData(B); + verify(manaHist).removeGemData(B); + verify(otherHist, never()).removeGemData(any()); + verify(mmo).setData(abilityStat, recalculated); + verify(mmo).setData(manaStat, recalculated); + verify(mmo).mergeData(manaStat, mana, B); + var merged = org.mockito.ArgumentCaptor.forClass(StatData.class); + verify(mmo).mergeData(eq(abilityStat), merged.capture(), eq(B)); + var copy = abilities.constructed().getFirst(); + assertEquals(List.of(copy), ((AbilityListData) merged.getValue()).getAbilities()); + verify(copy).setModifier("heal", 13.0); + verify(mmo, never()).mergeData(eq(gemOnly), any(), any()); + verify(mmo, never()).mergeData(eq(plain), any(), any()); + } + } + + @Test + void keepTriggersCopiesEachTriggerByPositionAndKeepsExtraAbilities() { + AbilityData first = mock(AbilityData.class); + AbilityData second = mock(AbilityData.class); + RegisteredSkill skill = mock(RegisteredSkill.class); + when(first.getAbility()).thenReturn(skill); + when(first.getModifiers()).thenReturn(Set.of()); + AbilityListData fresh = new AbilityListData(List.of(first, second)); + assertSame(fresh, RuneRefresher.keepTriggers(fresh, null)); + AbilityData old = mock(AbilityData.class); + when(old.getTrigger()).thenReturn(TriggerType.LEFT_CLICK); + List> arguments = new ArrayList<>(); + try (var abilities = + mockConstruction(AbilityData.class, (m, context) -> arguments.add(context.arguments()))) { + var kept = RuneRefresher.keepTriggers(fresh, new AbilityListData(List.of(old))); + assertEquals(List.of(abilities.constructed().getFirst(), second), kept.getAbilities()); + assertEquals(List.of(skill, TriggerType.LEFT_CLICK), arguments.getFirst()); + } + } +} From 31a19e24c3c98df4611e31c28fefe7e80caa8eb9 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:39:29 +0200 Subject: [PATCH 2/2] Skip player-owned menus; stamp a rune only after its replacement succeeds Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tfminecraft/magic/gear/RuneRefresher.java | 15 ++++++++------- .../magic/listener/GearRefreshListener.java | 5 ++++- .../net/tfminecraft/magic/ListenerTest.java | 1 + .../magic/gear/RuneRefresherTest.java | 18 ++++++++++++++---- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/main/java/net/tfminecraft/magic/gear/RuneRefresher.java b/src/main/java/net/tfminecraft/magic/gear/RuneRefresher.java index bc47acc..01df110 100644 --- a/src/main/java/net/tfminecraft/magic/gear/RuneRefresher.java +++ b/src/main/java/net/tfminecraft/magic/gear/RuneRefresher.java @@ -57,7 +57,8 @@ public static boolean isOutdated(ItemStack stack) { /** * Brings every outdated rune in {@code mmo} up to its template. {@code stack} is the - * weapon {@code mmo} was read from. Returns the revisions to stamp on the rebuilt weapon. + * weapon {@code mmo} was read from. Returns the revisions to stamp on the rebuilt weapon; + * a rune that could not be rebuilt is left out, so it is tried again next time. */ public static Map refresh(MMOItem mmo, ItemStack stack) { Map stamped = stamps(stack); @@ -67,10 +68,9 @@ public static Map refresh(MMOItem mmo, ItemStack stack) { if (live < 0) { continue; } - if (!Integer.valueOf(live).equals(stamped.get(gem.uuid()))) { - replace(mmo, gem); + if (Integer.valueOf(live).equals(stamped.get(gem.uuid())) || replace(mmo, gem)) { + revisions.put(gem.uuid(), live); } - revisions.put(gem.uuid(), live); } return revisions; } @@ -150,13 +150,13 @@ static int liveRevision(Socketed gem) { return template == null ? -1 : template.getRevisionId(); } - /** Same steps as MMOItems socketing a gem, but over the gem's existing history id. */ + /** Same steps as MMOItems socketing a gem, but over the gem's existing history id. False when the template could not be built. */ @SuppressWarnings({"rawtypes", "unchecked"}) - static void replace(MMOItem mmo, Socketed gem) { + static boolean replace(MMOItem mmo, Socketed gem) { MMOItem fresh = MMOItems.plugin.getMMOItem(MMOItems.plugin.getTypes().get(gem.type()), gem.id()); if (fresh == null) { Magic.plugin.getLogger().warning("[Magic] Rune " + gem.type() + "." + gem.id() + " could not be rebuilt; kept as socketed."); - return; + return false; } AbilityListData oldAbilities = null; for (StatHistory hist : new ArrayList<>(mmo.getStatHistories())) { @@ -180,6 +180,7 @@ static void replace(MMOItem mmo, Socketed gem) { } mmo.mergeData(stat, data, gem.uuid()); } + return true; } /** The template's abilities with the trigger each one had in the weapon, by position. */ diff --git a/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java b/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java index 26784cf..cf815b0 100644 --- a/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java +++ b/src/main/java/net/tfminecraft/magic/listener/GearRefreshListener.java @@ -2,6 +2,7 @@ import org.bukkit.block.DoubleChest; import org.bukkit.entity.Entity; +import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Item; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -108,8 +109,10 @@ public void onOpen(InventoryOpenEvent event) { later(() -> sweep(inventory, player)); } + /** Players are entities too, but a menu owned by a player is not storage. */ public static boolean isWorldStorage(InventoryHolder holder) { - return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest || holder instanceof Entity; + return holder instanceof BlockInventoryHolder || holder instanceof DoubleChest + || (holder instanceof Entity && !(holder instanceof HumanEntity)); } private static void sweep(Inventory inventory, Player player) { diff --git a/src/test/java/net/tfminecraft/magic/ListenerTest.java b/src/test/java/net/tfminecraft/magic/ListenerTest.java index c64c644..0b322df 100644 --- a/src/test/java/net/tfminecraft/magic/ListenerTest.java +++ b/src/test/java/net/tfminecraft/magic/ListenerTest.java @@ -251,6 +251,7 @@ void worldStorageCoversBlocksDoubleChestsAndEntitiesButNotMenus() { assertTrue(GearRefreshListener.isWorldStorage(mock(DoubleChest.class))); assertTrue(GearRefreshListener.isWorldStorage(mock(StorageMinecart.class))); assertFalse(GearRefreshListener.isWorldStorage(mock(InventoryHolder.class))); + assertFalse(GearRefreshListener.isWorldStorage(mock(Player.class))); assertFalse(GearRefreshListener.isWorldStorage(null)); } } diff --git a/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java b/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java index 1e12165..2ce4319 100644 --- a/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java +++ b/src/test/java/net/tfminecraft/magic/gear/RuneRefresherTest.java @@ -174,18 +174,28 @@ void outdatedComparesTheLiveTemplateRevisionWithTheStamp() { void refreshReplacesOnlyOutdatedRunesAndReportsTheirRevisions() { template("KNOWN", "CURRENT", 3); template("KNOWN", "STALE", 5); + template("KNOWN", "BUILDS", 7); + UUID d = UUID.fromString("00000000-0000-0000-0000-00000000000d"); var item = weapon(); RuneRefresher.stamp(item, Map.of(A, 3, B, 4)); when(MMOItems.plugin.getMMOItem(any(), anyString())).thenReturn(null); + MMOItem fresh = mock(MMOItem.class); + when(MMOItems.plugin.getMMOItem(any(), eq("BUILDS"))).thenReturn(fresh); MMOItem mmo = mock(MMOItem.class); try (var nbt = - nbt(sockets(gem(A, "KNOWN", "CURRENT"), gem(B, "KNOWN", "STALE"), gem(C, "GONE", "X")))) { - assertEquals(Map.of(A, 3, B, 5), RuneRefresher.refresh(mmo, item)); + nbt( + sockets( + gem(A, "KNOWN", "CURRENT"), + gem(B, "KNOWN", "STALE"), + gem(C, "GONE", "X"), + gem(d, "KNOWN", "BUILDS")))) { + // B could not be rebuilt, so it gets no stamp and is tried again next time. + assertEquals(Map.of(A, 3, d, 7), RuneRefresher.refresh(mmo, item)); } verify(MMOItems.plugin, times(1)).getMMOItem(any(), eq("STALE")); verify(MMOItems.plugin, never()).getMMOItem(any(), eq("CURRENT")); verify(logger).warning(contains("KNOWN.STALE")); - verifyNoInteractions(mmo); + verify(mmo, never()).mergeData(any(), any(), any()); } @Test @@ -227,7 +237,7 @@ void replaceMovesGemDataToTheTemplateAndKeepsTriggers() { when(MMOItems.plugin.getMMOItem(type, "STALE")).thenReturn(fresh); try (var abilities = mockConstruction(AbilityData.class)) { - RuneRefresher.replace(mmo, new RuneRefresher.Socketed(B, "KNOWN", "STALE")); + assertTrue(RuneRefresher.replace(mmo, new RuneRefresher.Socketed(B, "KNOWN", "STALE"))); verify(abilityHist).removeGemData(B); verify(manaHist).removeGemData(B); verify(otherHist, never()).removeGemData(any());