From 34fe4693c4a73ef5c92b32c92df6473ec2f24f45 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:04:09 +0000 Subject: [PATCH 1/2] fix: persist infusion tables across restarts --- .../geminfusion/InfusionBlock.java | 4 + .../geminfusion/InfusionEvents.java | 142 +++++++++---- .../tfminecraft/geminfusion/InfusionMain.java | 7 +- .../geminfusion/InfusionStationStore.java | 127 ++++++++++++ .../geminfusion/InfusionEventsTest.java | 104 +++++++++- .../geminfusion/InfusionStationStoreTest.java | 192 ++++++++++++++++++ .../geminfusion/LifecycleCommandsTest.java | 27 +++ 7 files changed, 555 insertions(+), 48 deletions(-) create mode 100644 src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java create mode 100644 src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionBlock.java b/src/main/java/net/tfminecraft/geminfusion/InfusionBlock.java index 16ae7ed..2124bdd 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionBlock.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionBlock.java @@ -4,6 +4,7 @@ import java.util.List; import org.bukkit.Location; +import org.bukkit.inventory.ItemStack; public class InfusionBlock { public Location loc; @@ -11,6 +12,9 @@ public class InfusionBlock { public Location particleLoc; public Integer infusionHits; public List currentGems = new ArrayList(); + public List pendingDrops = new ArrayList<>(); + + public record Drop(ItemStack item, String announcement) {} //Setters public void setToken(boolean b) { diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java index 7af7df1..9463703 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java @@ -1,8 +1,8 @@ package net.tfminecraft.geminfusion; import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.Objects; import org.bukkit.Bukkit; import org.bukkit.ChatColor; @@ -32,6 +32,49 @@ public class InfusionEvents implements Listener{ public List currentStations = new ArrayList(); + private final InfusionStationStore store; + private final List deliveries = new ArrayList<>(); + + public InfusionEvents() { + this(new InfusionStationStore()); + } + + InfusionEvents(InfusionStationStore store) { + this.store = store; + } + + public void loadPersisted() { + for (BukkitRunnable delivery : deliveries) delivery.cancel(); + deliveries.clear(); + currentStations.clear(); + currentStations.addAll(store.loadAll()); + for (InfusionBlock station : currentStations) { + if (station.getInfusionHits() == 5) scheduleDrops(station); + } + } + + public void flush() { + for (InfusionBlock station : currentStations) store.save(station); + } + + public void shutdown() { + for (BukkitRunnable delivery : deliveries) delivery.cancel(); + deliveries.clear(); + flush(); + } + + private boolean deposit(Player player, InfusionBlock station, Gemstone gem) { + station.addGem(gem); + if (!store.save(station)) { + station.getCurrentItems().removeLast(); + player.sendMessage(ChatColor.RED + "This infusion table could not be saved. Contact staff; your gem remains in your hand."); + return false; + } + ItemStack hand = player.getInventory().getItemInMainHand(); + hand.setAmount(hand.getAmount() - 1); + return true; + } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") @EventHandler @@ -94,8 +137,7 @@ public void addGemEvent(PlayerInteractEvent e) { } if(loc.equals(b.getLocation())) { exists = true; - p.getInventory().getItemInMainHand().setAmount(p.getInventory().getItemInMainHand().getAmount()-1); - b.addGem(gem); + if (!deposit(p, b, gem)) return; p.sendTitle(ChatColor.LIGHT_PURPLE + "Added " + gem.getColour() + gem.getName(), ChatColor.GRAY + "Current Gem Amount: " + ChatColor.YELLOW + b.getCurrentItems().size()+"/10", 1, 40, 20); b.getLocation().getWorld().spawnParticle(Particle.ENCHANT, b.getParticleLocation(), 60); p.getWorld().playSound(p.getLocation(), Sound.BLOCK_ENCHANTMENT_TABLE_USE, 0.7f, 2); @@ -106,8 +148,7 @@ public void addGemEvent(PlayerInteractEvent e) { iBlock.setLocation(loc); iBlock.setParticleLocation(particleLoc); iBlock.setInfustionHits(0); - p.getInventory().getItemInMainHand().setAmount(p.getInventory().getItemInMainHand().getAmount()-1); - iBlock.addGem(gem); + if (!deposit(p, iBlock, gem)) return; p.sendTitle(ChatColor.LIGHT_PURPLE + "Added " + gem.getColour() + gem.getName(), ChatColor.GRAY + "Current Gem Amount: " + ChatColor.YELLOW + iBlock.getCurrentItems().size()+"/10", 1, 40, 20); iBlock.getLocation().getWorld().spawnParticle(Particle.ENCHANT, iBlock.getParticleLocation(), 60); p.getWorld().playSound(p.getLocation(), Sound.BLOCK_ENCHANTMENT_TABLE_USE, 0.7f, 2); @@ -129,6 +170,7 @@ public void infuseHitEvent(PlayerInteractEvent e) { InfusionBlock b = currentStations.get(i); if(b.getLocation().equals(loc)) { e.setCancelled(true); + if (b.getInfusionHits() >= 5) return; if(b.getCurrentItems().size() > 0) { Player p = e.getPlayer(); ItemStack item = p.getInventory().getItemInMainHand(); @@ -137,68 +179,78 @@ public void infuseHitEvent(PlayerInteractEvent e) { String itemType = ConfigLoader.infusionStaff.split("\\.")[0]; String itemID = ConfigLoader.infusionStaff.split("\\.")[1]; if(nbt.getType().equalsIgnoreCase(itemType) && nbt.getString("MMOITEMS_ITEM_ID").equalsIgnoreCase(itemID)) { - // Resolve the whole batch before consuming the staff or clearing the bench. - // A later config reload must not strand an already consumed batch. - List batchRarities = new ArrayList<>(); + // Build once while the player's attributes are available; persist the rolled items. + List prepared = new ArrayList<>(); if (b.getInfusionHits() >= 4) { try { - for (Gemstone ignored : b.getCurrentItems()) batchRarities.add(getRarity()); - } catch (IllegalStateException invalidRarities) { - GoldsmithLog.warn(invalidRarities.getMessage()); - p.sendMessage(ChatColor.RED + "Infusion rarities are not configured correctly. Contact staff; your gems remain on the bench."); + for (Gemstone gem : b.getCurrentItems()) { + GemRarity rarity = getRarity(); + ItemStack output = Objects.requireNonNull(getInfusedGem(p, gem, b.getCurrentItems().size(), rarity), "Cannot build infused gem"); + String announcement = rarity.shouldAnnounce() + ? "§e" + p.getName() + " just infused a " + rarity.getName() + " " + gem.getColour() + gem.getName() + "§e Gemstone" : ""; + prepared.add(new InfusionBlock.Drop(output, announcement)); + } + } catch (RuntimeException invalidBatch) { + GoldsmithLog.warn(invalidBatch.getMessage()); + p.sendMessage(ChatColor.RED + "Infusion could not be prepared. Contact staff; your gems remain on the bench."); return; } } b.setInfustionHits(b.getInfusionHits()+1); + b.pendingDrops.addAll(prepared); + if (!store.save(b)) { + b.setInfustionHits(b.getInfusionHits()-1); + b.pendingDrops.clear(); + p.sendMessage(ChatColor.RED + "Infusion progress could not be saved. Contact staff; your gems remain on the bench."); + return; + } p.sendTitle(ChatColor.LIGHT_PURPLE + "Infusing...", ChatColor.GREEN + "Progress: " + ChatColor.YELLOW + b.getInfusionHits() + "/5", 1, 40, 20); b.getLocation().getWorld().spawnParticle(Particle.FLAME, b.getParticleLocation(), 60); b.getLocation().getWorld().spawnParticle(Particle.CRIT, b.getParticleLocation(), 60); p.getWorld().playSound(p.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_HIT, 1f, 0.2f*b.getInfusionHits()); p.getWorld().playSound(p.getLocation(), Sound.ENTITY_WITHER_SHOOT, 0.2f, 0.2f*b.getInfusionHits()); if(b.getInfusionHits() >= 5) { - currentStations.remove(b); p.getInventory().getItemInMainHand().setAmount(p.getInventory().getItemInMainHand().getAmount()-1); b.getParticleLocation().getWorld().spawnEntity(b.getParticleLocation(), EntityType.LIGHTNING_BOLT); b.getLocation().getWorld().spawnParticle(Particle.FLAME, b.getParticleLocation(), 600); p.getWorld().playSound(p.getLocation(), Sound.ENTITY_ENDER_DRAGON_GROWL, 1f, 0.7f); - new BukkitRunnable() - { - Integer i = 0; - public void run() - { - if(i < b.getCurrentItems().size()) { - Gemstone gem = b.getCurrentItems().get(i); - GemRarity r = batchRarities.get(i); - if(r.shouldAnnounce()) { - for(Player player : Bukkit.getOnlinePlayers()) { - player.sendMessage("§e"+p.getName()+" just infused a "+r.getName()+" "+gem.getColour()+gem.getName()+"§e Gemstone"); - } - } - ItemStack dropGem = getInfusedGem(p, gem, b.getCurrentItems().size(), r); - Location dropLocation = b.getLocation().clone().add(0.5, 1.1, 0.5); // Center-top of the block - Item dropped = p.getWorld().dropItem(dropLocation, dropGem); - - // Random upward and outward velocity - Vector velocity = new Vector( - (Math.random() - 0.5) * 0.2, // X: between -0.3 and +0.3 - 0.2 + Math.random() * 0.2, // Y: between 0.2 and 0.4 - (Math.random() - 0.5) * 0.2 // Z: between -0.3 and +0.3 - ); - dropped.setVelocity(velocity); - p.getWorld().playSound(p.getLocation(), Sound.ENTITY_GLOW_ITEM_FRAME_REMOVE_ITEM, 1f, 1f); - b.getLocation().getWorld().spawnParticle(Particle.FLAME, b.getParticleLocation(), 20); - } else { - this.cancel(); - } - i++; - } - }.runTaskTimer(InfusionMain.plugin, 30L, 2L); + scheduleDrops(b); } } } } } } + private void scheduleDrops(InfusionBlock station) { + BukkitRunnable delivery = new BukkitRunnable() { + @Override + public void run() { + // Retry failed checkpoints before delivering another item. + if (!store.save(station)) return; + if (station.pendingDrops.isEmpty()) { + store.delete(station); + currentStations.remove(station); + deliveries.remove(this); + cancel(); + return; + } + InfusionBlock.Drop output = station.pendingDrops.getFirst(); + Location dropLocation = station.getLocation().clone().add(0.5, 1.1, 0.5); + Item dropped = dropLocation.getWorld().dropItem(dropLocation, output.item()); + station.pendingDrops.removeFirst(); + store.save(station); + if (!output.announcement().isEmpty()) { + for (Player player : Bukkit.getOnlinePlayers()) player.sendMessage(output.announcement()); + } + dropped.setVelocity(new Vector((Math.random() - 0.5) * 0.2, 0.2 + Math.random() * 0.2, (Math.random() - 0.5) * 0.2)); + dropLocation.getWorld().playSound(dropLocation, Sound.ENTITY_GLOW_ITEM_FRAME_REMOVE_ITEM, 1f, 1f); + dropLocation.getWorld().spawnParticle(Particle.FLAME, station.getParticleLocation(), 20); + } + }; + delivery.runTaskTimer(InfusionMain.plugin, 30L, 2L); + deliveries.add(delivery); + } + public GemRarity getRarity() { return getRarity(Math.random()); } diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionMain.java b/src/main/java/net/tfminecraft/geminfusion/InfusionMain.java index 9f90960..a6cebbf 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionMain.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionMain.java @@ -46,6 +46,7 @@ public void onEnable() { config = getConfig(); loader.loadConfig(config); loadGoldsmithConfigs(); + events.loadPersisted(); getServer().getPluginManager().registerEvents(events, this); getServer().getPluginManager().registerEvents(goldsmithStations, this); @@ -56,12 +57,16 @@ public void onEnable() { getCommand(commands.cmd1).setExecutor(commands); getCommand(commands.cmd1).setTabCompleter(commands); goldsmithStations.loadPersisted(); - getServer().getScheduler().runTaskTimer(this, () -> goldsmithStations.flush(false), 20L * 60, 20L * 60); + getServer().getScheduler().runTaskTimer(this, () -> { + events.flush(); + goldsmithStations.flush(false); + }, 20L * 60, 20L * 60); GoldsmithLog.info("Loaded " + JewelryProjectLoader.get().size() + " goldsmithing projects"); } @Override public void onDisable() { + events.shutdown(); goldsmithStations.flush(true); } diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java b/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java new file mode 100644 index 0000000..e71a13c --- /dev/null +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java @@ -0,0 +1,127 @@ +package net.tfminecraft.geminfusion; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import net.tfminecraft.geminfusion.goldsmith.GoldsmithLog; + +/** One atomic snapshot per infusion table; rejected snapshots remain untouched. */ +final class InfusionStationStore { + private final Set rejected = new HashSet<>(); + + Path folder() { + return InfusionMain.plugin.getDataFolder().toPath().resolve("data/infusion-stations"); + } + + Path fileFor(Location location) { + return folder().resolve(location.getWorld().getUID() + "_" + location.getBlockX() + "_" + + location.getBlockY() + "_" + location.getBlockZ() + ".yml"); + } + + List loadAll() { + List stations = new ArrayList<>(); + rejected.clear(); + try { + Files.createDirectories(folder()); + try (var paths = Files.list(folder())) { + for (Path file : paths.filter(path -> path.toString().endsWith(".yml")).toList()) { + try { + stations.add(read(file)); + } catch (IOException | InvalidConfigurationException | IllegalArgumentException | NullPointerException ex) { + rejected.add(file); + GoldsmithLog.warn("Cannot load infusion table " + file.getFileName() + + "; retaining its saved gems: " + ex.getMessage()); + } + } + } + } catch (IOException ex) { + throw new IllegalStateException("Cannot read infusion tables; refusing to accept gems", ex); + } + return stations; + } + + boolean save(InfusionBlock station) { + Path file = fileFor(station.getLocation()); + try { + if (rejected.contains(file)) throw new IOException("Repair the retained snapshot before using this table"); + YamlConfiguration yaml = new YamlConfiguration(); + Location location = station.getLocation(); + yaml.set("world", location.getWorld().getName()); + yaml.set("x", location.getBlockX()); + yaml.set("y", location.getBlockY()); + yaml.set("z", location.getBlockZ()); + yaml.set("particle-x", station.getParticleLocation().getX()); + yaml.set("particle-y", station.getParticleLocation().getY()); + yaml.set("particle-z", station.getParticleLocation().getZ()); + yaml.set("hits", station.getInfusionHits()); + yaml.set("gems", station.getCurrentItems().stream().map(Gemstone::getId).toList()); + for (int i = 0; i < station.pendingDrops.size(); i++) { + InfusionBlock.Drop drop = station.pendingDrops.get(i); + yaml.set("drops." + i + ".item", drop.item()); + yaml.set("drops." + i + ".announcement", drop.announcement()); + } + Files.createDirectories(folder()); + Path temporary = Files.createTempFile(folder(), "infusion-", ".tmp"); + try { + yaml.save(temporary.toFile()); + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + return true; + } catch (IOException | IllegalArgumentException ex) { + GoldsmithLog.warn("Cannot save infusion table " + file.getFileName() + ": " + ex.getMessage()); + return false; + } + } + + void delete(InfusionBlock station) { + try { + Files.deleteIfExists(fileFor(station.getLocation())); + } catch (IOException ex) { + GoldsmithLog.warn("Cannot delete finished infusion table: " + ex.getMessage()); + } + } + + private InfusionBlock read(Path file) throws IOException, InvalidConfigurationException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.load(file.toFile()); + World world = Objects.requireNonNull(Bukkit.getWorld(Objects.requireNonNull(yaml.getString("world"))), "Missing world"); + InfusionBlock station = new InfusionBlock(); + station.setLocation(new Location(world, yaml.getInt("x"), yaml.getInt("y"), yaml.getInt("z"))); + if (!file.equals(fileFor(station.getLocation()))) throw new IllegalArgumentException("Table location does not match filename"); + station.setParticleLocation(new Location(world, yaml.getDouble("particle-x"), yaml.getDouble("particle-y"), yaml.getDouble("particle-z"))); + int hits = yaml.getInt("hits", -1); + if (hits < 0 || hits > 5) throw new IllegalArgumentException("Invalid infusion progress"); + station.setInfustionHits(hits); + for (String id : yaml.getStringList("gems")) { + Gemstone gem = ConfigLoader.loadedGems.stream().filter(candidate -> candidate.getId().equals(id)).findFirst().orElseThrow( + () -> new IllegalArgumentException("Unknown gem " + id)); + station.addGem(gem); + } + if (station.currentGems.isEmpty() || station.currentGems.size() > 10) throw new IllegalArgumentException("Invalid gem count"); + if (yaml.isConfigurationSection("drops")) { + for (String key : yaml.getConfigurationSection("drops").getKeys(false)) { + station.pendingDrops.add(new InfusionBlock.Drop( + Objects.requireNonNull(yaml.getItemStack("drops." + key + ".item"), "Missing infused gem"), + yaml.getString("drops." + key + ".announcement", ""))); + } + } + if (station.pendingDrops.size() > station.currentGems.size() + || (hits < 5 && !station.pendingDrops.isEmpty())) throw new IllegalArgumentException("Invalid pending output"); + return station; + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java index 694bb74..a3122c8 100644 --- a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java @@ -19,6 +19,7 @@ class InfusionEventsTest { InfusionEvents events; + InfusionStationStore store; World world; Player player; PlayerInventory inventory; @@ -31,7 +32,9 @@ class InfusionEventsTest { @BeforeEach void setup() { - events = new InfusionEvents(); + store = mock(InfusionStationStore.class); + when(store.save(any())).thenReturn(true); + events = new InfusionEvents(store); world = mock(World.class); player = mock(Player.class); inventory = mock(PlayerInventory.class); @@ -254,7 +257,7 @@ void fifthHitConsumesStaffAndScheduledTaskDropsBuiltGemThenStops() { when(scheduler.runTaskTimer(same(InfusionMain.plugin), capture.capture(), eq(30L), eq(2L))) .thenReturn(task); events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); - assertTrue(events.currentStations.isEmpty()); + assertEquals(List.of(s), events.currentStations); assertEquals(5, s.getInfusionHits()); verify(hand).setAmount(4); ConfigLoader.loadedRarities @@ -266,12 +269,109 @@ void fifthHitConsumesStaffAndScheduledTaskDropsBuiltGemThenStops() { verify(world, times(2)).dropItem(location.clone().add(.5, 1.1, .5), built); capture.getValue().run(); verify(scheduler).cancelTask(7); + assertTrue(events.currentStations.isEmpty()); verify(player).sendMessage(contains("just infused")); } finally { InfusionMain.plugin = previous; } } + @Test + void failedDepositCheckpointKeepsNewAndExistingGemsInHand() { + when(store.save(any())).thenReturn(false); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertTrue(events.currentStations.isEmpty()); + InfusionBlock existing = station(location, 2, 0); + events.addGemEvent(event(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(2, existing.currentGems.size()); + verify(hand, never()).setAmount(anyInt()); + verify(player, times(2)).sendMessage(contains("gem remains in your hand")); + } + + @Test + void failedProgressCheckpointAndFailedOutputBuildLeaveBatchAndStaffUntouched() { + InfusionBlock station = station(location, 2, 3); + when(nbt.getType()).thenReturn("STAFF"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + when(store.save(any())).thenReturn(false); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(3, station.getInfusionHits()); + station.setInfustionHits(4); + GemRarity rarity = mock(GemRarity.class); + when(rarity.getChance()).thenReturn(1.0); + ConfigLoader.loadedRarities.add(rarity); + try (var builder = mockStatic(InfusedGemBuilder.class); + var log = mockStatic(net.tfminecraft.geminfusion.goldsmith.GoldsmithLog.class)) { + ItemStack output = mock(ItemStack.class); + builder.when(() -> InfusedGemBuilder.buildInfusedGem(gem, rarity, 2, player)).thenReturn(output); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(4, station.getInfusionHits()); + assertTrue(station.pendingDrops.isEmpty()); + builder.when(() -> InfusedGemBuilder.buildInfusedGem(gem, rarity, 2, player)).thenReturn(output, null); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(4, station.getInfusionHits()); + assertTrue(station.pendingDrops.isEmpty()); + assertEquals(2, station.currentGems.size()); + verify(hand, never()).setAmount(anyInt()); + verify(player, times(3)).sendMessage(contains("gems remain on the bench")); + } + } + + @Test + void completedBenchRejectsMoreStaffHitsWhileWaitingForOutput() { + InfusionBlock station = station(location, 2, 5); + when(nbt.getType()).thenReturn("STAFF"); + when(nbt.getString("MMOITEMS_ITEM_ID")).thenReturn("INFUSER"); + events.infuseHitEvent(event(Action.LEFT_CLICK_BLOCK, EquipmentSlot.HAND)); + assertEquals(5, station.getInfusionHits()); + verify(hand, never()).setAmount(anyInt()); + verify(store, never()).save(any()); + } + + @Test + void restartResumesOnlyUndeliveredOutputWithoutPlayerAndRetriesFailedCheckpoints() { + InfusionBlock station = station(location, 2, 5); + ItemStack first = mock(ItemStack.class), second = mock(ItemStack.class); + station.pendingDrops.add(new InfusionBlock.Drop(first, "")); + station.pendingDrops.add(new InfusionBlock.Drop(second, "remaining announcement")); + when(store.loadAll()).thenReturn(List.of(station)); + InfusionMain previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(7); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(mock(Item.class)); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(player)); + var capture = org.mockito.ArgumentCaptor.forClass(Runnable.class); + when(scheduler.runTaskTimer(same(InfusionMain.plugin), capture.capture(), eq(30L), eq(2L))).thenReturn(task); + events.loadPersisted(); + capture.getValue().run(); + verify(world).dropItem(location.clone().add(.5, 1.1, .5), first); + assertEquals(1, station.pendingDrops.size()); + // Reload cancels the old delivery task; shutdown checkpoints the remainder. + events.loadPersisted(); + events.shutdown(); + verify(scheduler, times(2)).cancelTask(7); + events.loadPersisted(); + when(store.save(any())).thenReturn(false); + capture.getValue().run(); + assertEquals(1, station.pendingDrops.size()); + verify(world, never()).dropItem(any(Location.class), same(second)); + when(store.save(any())).thenReturn(true); + capture.getValue().run(); + capture.getValue().run(); + assertTrue(events.currentStations.isEmpty()); + verify(world, times(1)).dropItem(location.clone().add(.5, 1.1, .5), first); + verify(world, times(1)).dropItem(location.clone().add(.5, 1.1, .5), second); + verify(player).sendMessage("remaining announcement"); + verify(store).delete(station); + } finally { + InfusionMain.plugin = previous; + } + } + @Test void unrelatedNonfullStationIsNotChangedByAnotherBench() { InfusionBlock unrelated = station(location.clone().add(10, 0, 0), 1, 0); diff --git a/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java b/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java new file mode 100644 index 0000000..19bafeb --- /dev/null +++ b/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java @@ -0,0 +1,192 @@ +package net.tfminecraft.geminfusion; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.*; +import java.util.*; +import net.tfminecraft.geminfusion.goldsmith.GoldsmithLog; +import org.bukkit.*; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.*; + +class InfusionStationStoreTest { + @TempDir Path temp; + ServerMock server; + World world; + InfusionMain previous; + InfusionStationStore store; + Gemstone onyx, pearl, sunstone, green; + + @BeforeEach + void setup() { + server = MockBukkit.mock(); + world = server.addSimpleWorld("world"); + previous = InfusionMain.plugin; + InfusionMain.plugin = mock(InfusionMain.class); + when(InfusionMain.plugin.getDataFolder()).thenReturn(temp.toFile()); + when(InfusionMain.plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + ConfigLoader.loadedGems.clear(); + onyx = gem("onyx"); + pearl = gem("pearl"); + sunstone = gem("sunstone"); + green = gem("malachite"); + store = new InfusionStationStore(); + } + + @AfterEach + void cleanup() { + MockBukkit.unmock(); + InfusionMain.plugin = previous; + ConfigLoader.loadedGems.clear(); + } + + Gemstone gem(String id) { + Gemstone gem = new Gemstone(); + gem.setId(id); + ConfigLoader.loadedGems.add(gem); + return gem; + } + + InfusionBlock station(int hits) { + InfusionBlock station = new InfusionBlock(); + station.setLocation(new Location(world, -1, 64, 3)); + station.setParticleLocation(new Location(world, -.5, 65, 3.5)); + station.setInfustionHits(hits); + station.setCurrentGems(new ArrayList<>(List.of(onyx, onyx, onyx, pearl, pearl, pearl, pearl, sunstone, green, green))); + return station; + } + + @Test + void savesAndRestoresReportedMixedBatchAndProgressInSeparateWorlds() { + assertTrue(store.loadAll().isEmpty()); + InfusionBlock original = station(3); + InfusionBlock otherWorld = station(0); + World second = server.addSimpleWorld("second"); + otherWorld.getLocation().setWorld(second); + otherWorld.getParticleLocation().setWorld(second); + assertTrue(store.save(original)); + assertTrue(store.save(otherWorld)); + // Recreate the configured definitions, as startup does. + ConfigLoader.loadedGems.clear(); + for (String id : List.of("onyx", "pearl", "sunstone", "malachite")) gem(id); + List restored = new InfusionStationStore().loadAll(); + assertEquals(2, restored.size()); + InfusionBlock loaded = restored.stream().filter(s -> s.getLocation().getWorld().equals(world)).findFirst().orElseThrow(); + assertEquals(original.getLocation(), loaded.getLocation()); + assertEquals(original.getParticleLocation(), loaded.getParticleLocation()); + assertEquals(3, loaded.getInfusionHits()); + assertEquals(original.currentGems.stream().map(Gemstone::getId).toList(), loaded.currentGems.stream().map(Gemstone::getId).toList()); + assertSame(ConfigLoader.loadedGems.getFirst(), loaded.currentGems.getFirst()); + assertTrue(loaded.pendingDrops.isEmpty()); + } + + @Test + void preservesRolledItemMetadataAndOnlyRemainingDropsAndDeletesFinishedSnapshots() { + InfusionBlock completed = station(5); + ItemStack item = new ItemStack(Material.EMERALD); + var meta = item.getItemMeta(); + NamespacedKey key = new NamespacedKey("geminfusion", "test-roll"); + meta.getPersistentDataContainer().set(key, PersistentDataType.DOUBLE, 1.234); + item.setItemMeta(meta); + completed.pendingDrops.add(new InfusionBlock.Drop(item, "announcement")); + completed.pendingDrops.add(new InfusionBlock.Drop(new ItemStack(Material.DIAMOND), "")); + assertTrue(store.save(completed)); + InfusionBlock restored = new InfusionStationStore().loadAll().getFirst(); + assertEquals(completed.pendingDrops, restored.pendingDrops); + assertEquals(1.234, restored.pendingDrops.getFirst().item().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.DOUBLE)); + restored.pendingDrops.removeFirst(); + assertTrue(store.save(restored)); + assertEquals(List.of(new InfusionBlock.Drop(new ItemStack(Material.DIAMOND), "")), new InfusionStationStore().loadAll().getFirst().pendingDrops); + restored.pendingDrops.clear(); + assertTrue(store.save(restored)); + assertTrue(new InfusionStationStore().loadAll().getFirst().pendingDrops.isEmpty()); + store.delete(restored); + store.delete(restored); + assertTrue(store.loadAll().isEmpty()); + } + + @Test + void rejectedFilesRemainIntactAndBlockNewDepositsAtThatTable() throws Exception { + InfusionBlock original = station(0); + assertTrue(store.save(original)); + Path file = store.fileFor(original.getLocation()); + List> invalid = List.of( + y -> y.set("world", "missing"), + y -> y.set("world", null), + y -> y.set("x", 10), + y -> y.set("hits", -1), + y -> y.set("hits", 6), + y -> y.set("gems", List.of("unknown")), + y -> y.set("gems", List.of()), + y -> y.set("gems", Collections.nCopies(11, "onyx")), + y -> y.set("drops.0.announcement", "missing item"), + y -> y.set("drops.0.item", new ItemStack(Material.EMERALD)), + y -> { + y.set("hits", 5); + for (int i = 0; i < 11; i++) y.set("drops." + i + ".item", new ItemStack(Material.EMERALD)); + }); + String valid = Files.readString(file); + try (var log = mockStatic(GoldsmithLog.class)) { + for (var corrupt : invalid) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.loadFromString(valid); + corrupt.accept(yaml); + yaml.save(file.toFile()); + String rejected = Files.readString(file); + InfusionStationStore reloaded = new InfusionStationStore(); + assertTrue(reloaded.loadAll().isEmpty()); + assertFalse(reloaded.save(original)); + assertEquals(rejected, Files.readString(file)); + } + Files.writeString(file, "world: ["); + assertTrue(store.loadAll().isEmpty()); + assertFalse(store.save(original)); + assertEquals("world: [", Files.readString(file)); + Files.writeString(file, valid); + assertEquals(1, store.loadAll().size()); + assertTrue(store.save(original)); + } + } + + @Test + void reportsIoFailuresWithoutReplacingLastGoodSnapshotOrLeakingTemporaryFiles() throws Exception { + InfusionBlock original = station(0); + assertTrue(store.save(original)); + Path file = store.fileFor(original.getLocation()); + String saved = Files.readString(file); + var permissions = Files.getPosixFilePermissions(store.folder()); + try (var log = mockStatic(GoldsmithLog.class)) { + try { + Files.setPosixFilePermissions(store.folder(), Set.of()); + assertThrows(IllegalStateException.class, store::loadAll); + assertFalse(store.save(original)); + store.delete(original); + } finally { + Files.setPosixFilePermissions(store.folder(), permissions); + } + assertEquals(saved, Files.readString(file)); + var readable = Files.getPosixFilePermissions(file); + try { + Files.setPosixFilePermissions(file, Set.of()); + assertTrue(store.loadAll().isEmpty()); + } finally { + Files.setPosixFilePermissions(file, readable); + } + store.loadAll(); + Files.delete(file); + Files.createDirectory(file); + Files.writeString(file.resolve("occupied"), "keep"); + assertFalse(store.save(original)); + try (var paths = Files.list(store.folder())) { + assertEquals(List.of(file), paths.toList()); + } + Files.writeString(store.folder().resolve("ignored.tmp"), "ignored"); + assertTrue(store.loadAll().isEmpty()); + } + } +} diff --git a/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java b/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java index 7c0485e..621f0d4 100644 --- a/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/LifecycleCommandsTest.java @@ -58,6 +58,33 @@ void pluginLoadsResourcesRegistersCommandAutosavesReloadsAndFlushesOnDisable() { } } + @Test + void unfinishedInfusionSurvivesPluginRestart() throws Exception { + MockBukkit.createMockPlugin("TLibs"); + MockBukkit.createMockPlugin("MMOItems"); + MockBukkit.createMockPlugin("MythicLib"); + InfusionMain plugin = MockBukkit.load(InfusionMain.class); + var field = InfusionMain.class.getDeclaredField("events"); + field.setAccessible(true); + InfusionEvents events = (InfusionEvents) field.get(plugin); + InfusionBlock station = new InfusionBlock(); + Location location = server.addSimpleWorld("world").getBlockAt(1, 2, 3).getLocation(); + station.setLocation(location); + station.setParticleLocation(location.clone().add(.5, 1, .5)); + station.setInfustionHits(0); + for (int i = 0; i < 10; i++) station.addGem(ConfigLoader.loadedGems.get(i % 4)); + List expected = station.getCurrentItems().stream().map(Gemstone::getId).toList(); + events.currentStations.add(station); + plugin.onDisable(); + events.currentStations.clear(); + plugin.onEnable(); + assertEquals(1, events.currentStations.size(), "Restart must restore deposited gems"); + InfusionBlock restored = events.currentStations.getFirst(); + assertEquals(location, restored.getLocation()); + assertEquals(0, restored.getInfusionHits()); + assertEquals(expected, restored.getCurrentItems().stream().map(Gemstone::getId).toList()); + } + @Test void commandsCoverUsageReloadPermissionsAndProjectSelection() { CommandManager commands = new CommandManager(); From 6f6bb6fe67098ba2ca384783615e396575189675 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:15:37 +0000 Subject: [PATCH 2/2] fix: flush infusion snapshots and retry completed cleanup --- .../geminfusion/InfusionEvents.java | 2 +- .../geminfusion/InfusionStationStore.java | 22 ++++++++++++++++--- .../geminfusion/InfusionEventsTest.java | 8 ++++++- .../geminfusion/InfusionStationStoreTest.java | 22 ++++++++++++++++--- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java index 9463703..66dec44 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionEvents.java @@ -228,7 +228,7 @@ public void run() { // Retry failed checkpoints before delivering another item. if (!store.save(station)) return; if (station.pendingDrops.isEmpty()) { - store.delete(station); + if (!store.delete(station)) return; currentStations.remove(station); deliveries.remove(this); cancel(); diff --git a/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java b/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java index e71a13c..5907e63 100644 --- a/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java +++ b/src/main/java/net/tfminecraft/geminfusion/InfusionStationStore.java @@ -1,9 +1,13 @@ package net.tfminecraft.geminfusion; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -55,6 +59,7 @@ List loadAll() { boolean save(InfusionBlock station) { Path file = fileFor(station.getLocation()); + boolean replaced = false; try { if (rejected.contains(file)) throw new IOException("Repair the retained snapshot before using this table"); YamlConfiguration yaml = new YamlConfiguration(); @@ -76,23 +81,34 @@ boolean save(InfusionBlock station) { Files.createDirectories(folder()); Path temporary = Files.createTempFile(folder(), "infusion-", ".tmp"); try { - yaml.save(temporary.toFile()); + ByteBuffer bytes = ByteBuffer.wrap(yaml.saveToString().getBytes(StandardCharsets.UTF_8)); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + while (bytes.hasRemaining()) channel.write(bytes); + channel.force(true); + } Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + replaced = true; + try (FileChannel directory = FileChannel.open(folder(), StandardOpenOption.READ)) { + directory.force(true); + } } finally { Files.deleteIfExists(temporary); } return true; } catch (IOException | IllegalArgumentException ex) { GoldsmithLog.warn("Cannot save infusion table " + file.getFileName() + ": " + ex.getMessage()); - return false; + // A post-rename sync failure must not roll back items already recorded on disk. + return replaced; } } - void delete(InfusionBlock station) { + boolean delete(InfusionBlock station) { try { Files.deleteIfExists(fileFor(station.getLocation())); + return true; } catch (IOException ex) { GoldsmithLog.warn("Cannot delete finished infusion table: " + ex.getMessage()); + return false; } } diff --git a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java index a3122c8..e2e8a70 100644 --- a/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/InfusionEventsTest.java @@ -34,6 +34,7 @@ class InfusionEventsTest { void setup() { store = mock(InfusionStationStore.class); when(store.save(any())).thenReturn(true); + when(store.delete(any())).thenReturn(true); events = new InfusionEvents(store); world = mock(World.class); player = mock(Player.class); @@ -361,12 +362,17 @@ void restartResumesOnlyUndeliveredOutputWithoutPlayerAndRetriesFailedCheckpoints verify(world, never()).dropItem(any(Location.class), same(second)); when(store.save(any())).thenReturn(true); capture.getValue().run(); + when(store.delete(any())).thenReturn(false); + capture.getValue().run(); + assertEquals(List.of(station), events.currentStations); + assertTrue(station.pendingDrops.isEmpty()); + when(store.delete(any())).thenReturn(true); capture.getValue().run(); assertTrue(events.currentStations.isEmpty()); verify(world, times(1)).dropItem(location.clone().add(.5, 1.1, .5), first); verify(world, times(1)).dropItem(location.clone().add(.5, 1.1, .5), second); verify(player).sendMessage("remaining announcement"); - verify(store).delete(station); + verify(store, times(2)).delete(station); } finally { InfusionMain.plugin = previous; } diff --git a/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java b/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java index 19bafeb..cdf059b 100644 --- a/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java +++ b/src/test/java/net/tfminecraft/geminfusion/InfusionStationStoreTest.java @@ -4,6 +4,7 @@ import static org.mockito.Mockito.*; import java.nio.file.*; +import java.nio.channels.FileChannel; import java.util.*; import net.tfminecraft.geminfusion.goldsmith.GoldsmithLog; import org.bukkit.*; @@ -105,8 +106,8 @@ void preservesRolledItemMetadataAndOnlyRemainingDropsAndDeletesFinishedSnapshots restored.pendingDrops.clear(); assertTrue(store.save(restored)); assertTrue(new InfusionStationStore().loadAll().getFirst().pendingDrops.isEmpty()); - store.delete(restored); - store.delete(restored); + assertTrue(store.delete(restored)); + assertTrue(store.delete(restored)); assertTrue(store.loadAll().isEmpty()); } @@ -165,7 +166,7 @@ void reportsIoFailuresWithoutReplacingLastGoodSnapshotOrLeakingTemporaryFiles() Files.setPosixFilePermissions(store.folder(), Set.of()); assertThrows(IllegalStateException.class, store::loadAll); assertFalse(store.save(original)); - store.delete(original); + assertFalse(store.delete(original)); } finally { Files.setPosixFilePermissions(store.folder(), permissions); } @@ -189,4 +190,19 @@ void reportsIoFailuresWithoutReplacingLastGoodSnapshotOrLeakingTemporaryFiles() assertTrue(store.loadAll().isEmpty()); } } + + @Test + void directorySyncFailureAfterRenameKeepsTheCommittedStateAccepted() throws Exception { + InfusionBlock original = station(0); + assertTrue(store.save(original)); + original.setInfustionHits(1); + try (var channels = mockStatic(FileChannel.class, CALLS_REAL_METHODS); + var log = mockStatic(GoldsmithLog.class)) { + channels.when(() -> FileChannel.open(store.folder(), StandardOpenOption.READ)) + .thenThrow(new java.io.IOException("Directory sync failed")); + assertTrue(store.save(original), "A committed deposit must still consume its held gem"); + assertEquals(1, new InfusionStationStore().loadAll().getFirst().getInfusionHits()); + log.verify(() -> GoldsmithLog.warn(contains("Directory sync failed"))); + } + } }