diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java index 6d343ca..7e4eaba 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -29,6 +29,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { private final TeleportPolicy teleportPolicy; private final BaseFlightController flightController; private final PocketBaseManager pocketBases; + private final PocketBaseRecipeDiscovery recipeDiscovery; private final DialogProvider dialogProvider; BaseSettingsCommand( @@ -44,12 +45,26 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { PluginSettingsProvider settings, BaseFlightController flightController, PocketBaseManager pocketBases + ) { + this( + stateManager, settings, flightController, pocketBases, + (PocketBaseRecipeDiscovery) null + ); + } + + BaseSettingsCommand( + BaseStateManager stateManager, + PluginSettingsProvider settings, + BaseFlightController flightController, + PocketBaseManager pocketBases, + PocketBaseRecipeDiscovery recipeDiscovery ) { this( stateManager, settings, flightController, pocketBases, + recipeDiscovery, (owner, pocket) -> new BaseSettingsDialogFactory(settings.current()) .create(owner, pocket) ); @@ -61,12 +76,24 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { BaseFlightController flightController, PocketBaseManager pocketBases, DialogProvider dialogProvider + ) { + this(stateManager, settings, flightController, pocketBases, null, dialogProvider); + } + + BaseSettingsCommand( + BaseStateManager stateManager, + PluginSettingsProvider settings, + BaseFlightController flightController, + PocketBaseManager pocketBases, + PocketBaseRecipeDiscovery recipeDiscovery, + DialogProvider dialogProvider ) { this.stateManager = stateManager; this.settings = settings; this.teleportPolicy = new TeleportPolicy(settings); this.flightController = flightController; this.pocketBases = pocketBases; + this.recipeDiscovery = recipeDiscovery; this.dialogProvider = dialogProvider; } @@ -202,6 +229,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { + "The Pocket Base upgrade failed; your payment was restored."); return true; } + if (recipeDiscovery != null) { + recipeDiscovery.discoverCreation(player); + } player.sendTitle( ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked", ChatColor.YELLOW + "Build a portal inside your base", diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java b/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java index cb67a6e..6cc3505 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseKeystoneService.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; import java.util.logging.Level; import java.util.logging.Logger; import net.kyori.adventure.text.Component; @@ -26,8 +28,10 @@ import org.bukkit.event.inventory.PrepareItemCraftEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.recipe.CraftingBookCategory; import org.bukkit.persistence.PersistentDataType; final class PocketBaseKeystoneService implements Listener { @@ -46,6 +50,8 @@ final class PocketBaseKeystoneService implements Listener { private final KeystoneReturnStore returns; private final NamespacedKey markerKey; private final Logger logger; + private BooleanSupplier recipesAvailable = () -> true; + private Predicate recipeControlled = this::ourRecipe; PocketBaseKeystoneService( Server server, @@ -61,18 +67,30 @@ final class PocketBaseKeystoneService implements Listener { this.logger = logger; } + void setRecipesAvailable(BooleanSupplier available) { + this.recipesAvailable = available; + } + + void setRecipeControl(Predicate controlled) { + this.recipeControlled = controlled; + } + @EventHandler(priority = EventPriority.HIGH) public void onPrepareCraft(PrepareItemCraftEvent event) { - if (!(event.getRecipe() instanceof ShapedRecipe shaped) - || !shaped.getKey().equals(markerKey) - || !(event.getView().getPlayer() instanceof Player player)) { + if (!(event.getView().getPlayer() instanceof Player player) + || !recipeControlled.test(event.getRecipe())) { return; } - if (pocketBases.state(player.getUniqueId()).level() < 1) { + if (!recipesAvailable.getAsBoolean() + || pocketBases.state(player.getUniqueId()).level() < 1) { event.getInventory().setResult(null); } } + private boolean ourRecipe(Recipe recipe) { + return recipe instanceof ShapedRecipe shaped && shaped.getKey().equals(markerKey); + } + @EventHandler(priority = EventPriority.HIGH) public void onUse(PlayerInteractEvent event) { Action action = event.getAction(); @@ -200,8 +218,12 @@ final class PocketBaseKeystoneService implements Listener { return marker != null && marker == (byte) 1; } + ShapedRecipe recipe() { + return recipe(markerKey, createKeystone(markerKey)); + } + void registerRecipe() { - server.addRecipe(recipe(markerKey, createKeystone(markerKey))); + server.addRecipe(recipe()); } static ItemStack createKeystone(NamespacedKey markerKey) { @@ -244,6 +266,7 @@ final class PocketBaseKeystoneService implements Listener { recipe.setIngredient('O', specification.obsidian()); recipe.setIngredient('N', specification.netherite()); recipe.setIngredient('E', specification.enderPearl()); + recipe.setCategory(CraftingBookCategory.EQUIPMENT); return recipe; } diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseRecipeDiscovery.java b/src/main/java/games/dmg/spigotbase/PocketBaseRecipeDiscovery.java new file mode 100644 index 0000000..7cb63d7 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseRecipeDiscovery.java @@ -0,0 +1,117 @@ +package games.dmg.spigotbase; + +import java.util.List; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.ItemStack; + +/** Awards recipe-book knowledge; crafting handlers remain the authority. */ +final class PocketBaseRecipeDiscovery implements Listener { + private final PocketBaseRecipeRegistry registry; + private final PocketBaseManager pockets; + private final PocketBaseSharingCompass sharing; + private final List creationKeys; + private final NamespacedKey rechargeKey; + private final Logger logger; + + PocketBaseRecipeDiscovery( + PocketBaseRecipeRegistry registry, + PocketBaseManager pockets, + PocketBaseSharingCompass sharing, + NamespacedKey keystoneKey, + NamespacedKey sharingCreationKey, + NamespacedKey rechargeKey, + Logger logger + ) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.pockets = Objects.requireNonNull(pockets, "pockets"); + this.sharing = Objects.requireNonNull(sharing, "sharing"); + this.creationKeys = List.of(keystoneKey, sharingCreationKey); + this.rechargeKey = Objects.requireNonNull(rechargeKey, "recharge key"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + void reconcileOnline(Iterable players) { + for (Player player : players) { + reconcile(player); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + reconcile(event.getPlayer()); + } + + void reconcile(Player player) { + try { + sharing.normalizeCarried(player); + if (!registry.isAvailable()) { + return; + } + if (pockets.state(player.getUniqueId()).level() > 0) { + for (NamespacedKey key : creationKeys) { + awardSafely(player, key); + } + } + if (carriesAuthenticCompass(player)) { + awardSafely(player, rechargeKey); + } + } catch (RuntimeException exception) { + logger.log(Level.WARNING, + "Could not reconcile Pocket Base recipe discovery for " + player.getUniqueId(), + exception); + } + } + + void discoverCreation(Player player) { + if (!registry.isAvailable()) { + return; + } + for (NamespacedKey key : creationKeys) { + awardSafely(player, key); + } + } + + void discoverRecharge(Player player) { + if (registry.isAvailable()) { + awardSafely(player, rechargeKey); + } + } + + private boolean carriesAuthenticCompass(Player player) { + for (ItemStack item : player.getInventory().getStorageContents()) { + if (sharing.isAuthentic(item)) { + return true; + } + } + return sharing.isAuthentic(player.getInventory().getItemInOffHand()); + } + + private void awardSafely(Player player, NamespacedKey key) { + try { + award(player, key); + } catch (RuntimeException exception) { + logger.log(Level.WARNING, + "Could not discover Pocket Base recipe " + key + " for " + + player.getUniqueId(), exception); + } + } + + private void award(Player player, NamespacedKey key) { + if (player.hasDiscoveredRecipe(key)) { + return; + } + player.discoverRecipe(key); + if (!player.hasDiscoveredRecipe(key)) { + logger.warning("Pocket Base recipe discovery postcondition was not met for " + + player.getUniqueId() + " and " + key + "; a later reconciliation will retry"); + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseRecipeRegistry.java b/src/main/java/games/dmg/spigotbase/PocketBaseRecipeRegistry.java new file mode 100644 index 0000000..aaa2d4a --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/PocketBaseRecipeRegistry.java @@ -0,0 +1,240 @@ +package games.dmg.spigotbase; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapedRecipe; + +/** Owns only recipes successfully added during this plugin lifecycle. */ +final class PocketBaseRecipeRegistry implements AutoCloseable { + private static final int REQUIRED_RECIPE_COUNT = 3; + private final Server server; + private final List recipes; + private final Logger logger; + private final Map owned = new LinkedHashMap<>(); + private boolean attempted; + private boolean available; + + PocketBaseRecipeRegistry(Server server, List recipes, Logger logger) { + this.server = Objects.requireNonNull(server, "server"); + this.recipes = List.copyOf(recipes); + this.logger = Objects.requireNonNull(logger, "logger"); + if (this.recipes.size() != REQUIRED_RECIPE_COUNT) { + throw new IllegalArgumentException("Pocket Base travel requires exactly three recipes"); + } + Set keys = new HashSet<>(); + String lifecycle = UUID.randomUUID().toString(); + for (Recipe recipe : this.recipes) { + if (!(recipe instanceof Keyed keyed) || !keys.add(keyed.getKey())) { + throw new IllegalArgumentException("Pocket Base travel recipes require three unique keys"); + } + if (recipe instanceof ShapedRecipe shaped) { + shaped.setGroup("spigotbase-owned-" + lifecycle + "-" + keyed.getKey()); + } + } + } + + boolean register() { + if (attempted) { + return available; + } + attempted = true; + try { + for (Recipe recipe : recipes) { + NamespacedKey key = ((Keyed) recipe).getKey(); + boolean added; + try { + added = server.addRecipe(recipe); + } catch (RuntimeException exception) { + try { + if (sameDefinition(recipe, server.getRecipe(key))) { + owned.put(key, recipe); + } + } catch (RuntimeException inspectionFailure) { + exception.addSuppressed(inspectionFailure); + } + throw exception; + } + if (!added) { + logger.warning("Pocket Base travel recipe registration is incomplete at " + + key + "; recipe discovery is disabled for this lifecycle"); + rollback(); + return false; + } + owned.put(key, recipe); + } + available = true; + return true; + } catch (RuntimeException exception) { + logger.log(Level.SEVERE, + "Pocket Base travel recipe registration failed; recipe discovery is disabled for this lifecycle", + exception); + rollback(); + return false; + } + } + + boolean isAvailable() { + if (!available) { + return false; + } + for (var entry : owned.entrySet()) { + try { + if (!sameDefinition(entry.getValue(), server.getRecipe(entry.getKey()))) { + available = false; + return false; + } + } catch (RuntimeException exception) { + logger.log(Level.WARNING, + "Could not verify active Pocket Base travel recipe " + entry.getKey(), + exception); + return false; + } + } + return owned.size() == REQUIRED_RECIPE_COUNT; + } + + boolean controls(Recipe candidate) { + if (!(candidate instanceof Keyed keyed)) { + return false; + } + Recipe definition = owned.get(keyed.getKey()); + if (!sameDefinition(definition, candidate)) { + return false; + } + try { + boolean controls = sameDefinition(definition, server.getRecipe(keyed.getKey())); + if (!controls) { + available = false; + } + return controls; + } catch (RuntimeException exception) { + available = false; + logger.log(Level.WARNING, + "Could not verify active Pocket Base travel recipe " + keyed.getKey() + + "; keeping its handler fail-closed", + exception); + return true; + } + } + + private void rollback() { + available = false; + for (var entry : Map.copyOf(owned).entrySet()) { + NamespacedKey key = entry.getKey(); + Recipe active; + try { + active = server.getRecipe(key); + } catch (RuntimeException exception) { + logger.log(Level.SEVERE, + "Could not verify owned Pocket Base travel recipe " + key, exception); + continue; + } + if (!sameDefinition(entry.getValue(), active)) { + owned.remove(key); + continue; + } + try { + if (server.removeRecipe(key)) { + owned.remove(key); + } + } catch (RuntimeException exception) { + logger.log(Level.SEVERE, "Could not remove owned Pocket Base travel recipe " + key, + exception); + } + } + } + + private static boolean sameDefinition(Recipe expected, Recipe actual) { + if (expected == actual) { + return true; + } + if (!(expected instanceof ShapedRecipe expectedShaped) + || !(actual instanceof ShapedRecipe actualShaped)) { + return false; + } + if (!expectedShaped.getKey().equals(actualShaped.getKey()) + || !expectedShaped.getResult().equals(actualShaped.getResult()) + || !sameIngredientGrid(expectedShaped, actualShaped) + || !expectedShaped.getGroup().equals(actualShaped.getGroup()) + || expectedShaped.getCategory() != actualShaped.getCategory()) { + return false; + } + return true; + } + + private static boolean sameIngredientGrid( + ShapedRecipe expected, ShapedRecipe actual) { + String[] expectedShape = expected.getShape(); + String[] actualShape = actual.getShape(); + if (expectedShape.length != actualShape.length) { + return false; + } + Map expectedChoices = expected.getChoiceMap(); + Map actualChoices = actual.getChoiceMap(); + for (int row = 0; row < expectedShape.length; row++) { + if (expectedShape[row].length() != actualShape[row].length()) { + return false; + } + for (int column = 0; column < expectedShape[row].length(); column++) { + RecipeChoice expectedChoice = expectedChoices.get( + expectedShape[row].charAt(column)); + RecipeChoice actualChoice = actualChoices.get( + actualShape[row].charAt(column)); + if (!sameChoice(expectedChoice, actualChoice)) { + return false; + } + } + } + return true; + } + + private static boolean sameChoice(RecipeChoice expected, RecipeChoice actual) { + Set expectedMaterials = materialKeys(expected); + Set actualMaterials = materialKeys(actual); + if (expectedMaterials != null || actualMaterials != null) { + return Objects.equals(expectedMaterials, actualMaterials); + } + if (expected instanceof RecipeChoice.ExactChoice expectedItems + && actual instanceof RecipeChoice.ExactChoice actualItems) { + List expectedChoices = expectedItems.getChoices(); + List actualChoices = actualItems.getChoices(); + return expectedChoices.equals(actualChoices); + } + return Objects.equals(expected, actual); + } + + private static Set materialKeys(RecipeChoice choice) { + Set keys = new HashSet<>(); + if (choice instanceof RecipeChoice.MaterialChoice materials) { + for (var material : materials.getChoices()) { + keys.add(material.getKey().asString()); + } + return Set.copyOf(keys); + } + if (choice instanceof RecipeChoice.ItemTypeChoice itemTypes) { + for (var itemType : itemTypes.itemTypes().values()) { + keys.add(itemType.key().asString()); + } + return Set.copyOf(keys); + } + return null; + } + + @Override + public void close() { + rollback(); + } +} diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java b/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java index 077b74b..644c847 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseSharingCompass.java @@ -4,7 +4,9 @@ import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.function.BooleanSupplier; import java.util.function.IntSupplier; +import java.util.function.Predicate; import net.kyori.adventure.text.Component; import org.bukkit.Keyed; import org.bukkit.Location; @@ -23,10 +25,14 @@ import org.bukkit.event.inventory.CraftItemEvent; import org.bukkit.event.inventory.InventoryType; import org.bukkit.event.inventory.PrepareItemCraftEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerRecipeBookClickEvent; import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.RecipeChoice; import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.inventory.recipe.CraftingBookCategory; import org.bukkit.persistence.PersistentDataType; /** Destination-bound guest access, separate from holder-bound Keystones. */ @@ -41,6 +47,9 @@ final class PocketBaseSharingCompass implements Listener { private final NamespacedKey ownerNameKey; private final IntSupplier roll; private final Set travelling = new HashSet<>(); + private PocketBaseRecipeDiscovery recipeDiscovery; + private BooleanSupplier recipesAvailable = () -> true; + private Predicate recipeControlled = this::ourRecipe; PocketBaseSharingCompass(Server server, PocketBaseManager pockets, NamespacedKey key, IntSupplier roll) { this.server = server; @@ -54,24 +63,69 @@ final class PocketBaseSharingCompass implements Listener { this.roll = roll; } - void registerRecipes() { - var creation = new ShapedRecipe(key, new ItemStack(Material.COMPASS)); + void setRecipeDiscovery(PocketBaseRecipeDiscovery discovery) { + this.recipeDiscovery = discovery; + } + + void setRecipesAvailable(BooleanSupplier available) { + this.recipesAvailable = available; + } + + void setRecipeControl(Predicate controlled) { + this.recipeControlled = controlled; + } + + List recipes() { + var creation = new ShapedRecipe(key, representative("Creation")); creation.shape("DED", "ECE", "DED"); creation.setIngredient('D', Material.DIAMOND_BLOCK); creation.setIngredient('E', Material.ECHO_SHARD); creation.setIngredient('C', Material.END_CRYSTAL); - server.addRecipe(creation); - var recharge = new ShapedRecipe(rechargeKey, new ItemStack(Material.COMPASS)); + creation.setCategory(CraftingBookCategory.EQUIPMENT); + var recharge = new ShapedRecipe(rechargeKey, representative("Recharge")); recharge.shape("PPP", "PCP", "PPP"); recharge.setIngredient('P', Material.ENDER_PEARL); - recharge.setIngredient('C', Material.COMPASS); - server.addRecipe(recharge); + recharge.setIngredient('C', new RecipeChoice.MaterialChoice(Material.COMPASS)); + recharge.setCategory(CraftingBookCategory.EQUIPMENT); + return List.of(creation, recharge); + } + + void registerRecipes() { + recipes().forEach(server::addRecipe); + } + + private static ItemStack representative(String purpose) { + ItemStack item = new ItemStack(Material.COMPASS); + var meta = item.getItemMeta(); + meta.displayName(Component.text( + "Pocket Base Sharing Compass — " + purpose + " Representative")); + meta.lore(List.of( + Component.text("Non-authoritative recipe-book display only."), + Component.text("Only an authenticated bound Sharing Compass is usable.") + )); + item.setItemMeta(meta); + return item; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onRecipeBookClick(PlayerRecipeBookClickEvent event) { + if (event.getOriginalRecipe() instanceof Keyed keyed + && rechargeKey.equals(keyed.getKey()) + && recipeControlled.test(event.getOriginalRecipe())) { + normalizeStorage(event.getPlayer()); + } } @EventHandler(priority = EventPriority.HIGHEST) public void onPrepare(PrepareItemCraftEvent event) { if (!(event.getView().getPlayer() instanceof Player player) - || !ourRecipe(event.getRecipe()) || empty(event.getInventory().getResult())) return; + || !recipeControlled.test(event.getRecipe()) + || empty(event.getInventory().getResult())) return; + // Only definitions still owned by this lifecycle are intercepted. + if (!recipesAvailable.getAsBoolean()) { + event.getInventory().setResult(null); + return; + } // Only our candidate matrices/results are replaced; unrelated recipes remain untouched. ItemStack[] matrix = event.getInventory().getMatrix(); if (rechargeCandidate(matrix)) { @@ -86,8 +140,12 @@ final class PocketBaseSharingCompass implements Listener { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onCraft(CraftItemEvent event) { - if (!ourRecipe(event.getRecipe())) return; + if (!recipeControlled.test(event.getRecipe())) return; event.setCancelled(true); + if (!recipesAvailable.getAsBoolean()) { + event.getInventory().setResult(null); + return; + } ItemStack[] matrix = event.getInventory().getMatrix(); boolean creation = creationMatrix(matrix); boolean recharge = rechargeCandidate(matrix); @@ -126,6 +184,9 @@ final class PocketBaseSharingCompass implements Listener { table.getWorld().createExplosion(player, center, 6.0F, false, true, false); } else if (shift) player.getInventory().setItem(slot, output); else event.getView().setCursor(output); + if (creation && output != null && recipeDiscovery != null) { + recipeDiscovery.discoverRecharge(player); + } } @EventHandler(priority = EventPriority.HIGHEST) @@ -136,6 +197,10 @@ final class PocketBaseSharingCompass implements Listener { ItemStack item = player.getInventory().getItem(event.getHand()); State state = state(item); if (state == null || !item.equals(event.getItem())) return; + ItemStack normalized = normalizeAuthentic(item); + if (normalized != item) { + player.getInventory().setItem(event.getHand(), normalized); + } event.setCancelled(true); if (state.charges() == 0 || pockets.state(state.owner()).level() < 1 || pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()) return; @@ -148,6 +213,9 @@ final class PocketBaseSharingCompass implements Listener { teleported = player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN); if (teleported && !player.isDead() && player.getWorld().equals(destination.orElseThrow().getWorld())) { spendCarriedCharge(player, state.identity()); + if (recipeDiscovery != null) { + recipeDiscovery.discoverRecharge(player); + } } } finally { if (!teleported) player.setFallDistance(fallDistance); @@ -220,6 +288,48 @@ final class PocketBaseSharingCompass implements Listener { return true; } + boolean isAuthentic(ItemStack item) { + return state(item) != null; + } + + ItemStack normalizeAuthentic(ItemStack item) { + if (state(item) == null) { + return item; + } + var meta = item.getItemMeta(); + if (!meta.hasCustomName()) { + return item; + } + Component visibleTitle = meta.customName(); + ItemStack normalized = item.clone(); + var normalizedMeta = normalized.getItemMeta(); + normalizedMeta.itemName(visibleTitle); + normalizedMeta.customName(null); + normalized.setItemMeta(normalizedMeta); + return normalized; + } + + void normalizeCarried(Player player) { + normalizeStorage(player); + var inventory = player.getInventory(); + ItemStack offhand = inventory.getItemInOffHand(); + ItemStack normalizedOffhand = normalizeAuthentic(offhand); + if (normalizedOffhand != offhand) { + inventory.setItem(EquipmentSlot.OFF_HAND, normalizedOffhand); + } + } + + private void normalizeStorage(Player player) { + var inventory = player.getInventory(); + ItemStack[] storage = inventory.getStorageContents(); + for (int slot = 0; slot < storage.length; slot++) { + ItemStack normalized = normalizeAuthentic(storage[slot]); + if (normalized != storage[slot]) { + inventory.setItem(slot, normalized); + } + } + } + private State state(ItemStack item) { if (!material(item, Material.COMPASS) || item.getAmount() != 1) return null; var meta = item.getItemMeta(); @@ -242,7 +352,10 @@ final class PocketBaseSharingCompass implements Listener { } private ItemStack withCharges(ItemStack item, long charges) { - var updated = item.clone(); + ItemStack updated = normalizeAuthentic(item); + if (updated == item) { + updated = item.clone(); + } var meta = updated.getItemMeta(); var data = meta.getPersistentDataContainer(); data.set(chargesKey, PersistentDataType.LONG, charges); @@ -290,7 +403,7 @@ final class PocketBaseSharingCompass implements Listener { data.set(ownerNameKey, PersistentDataType.STRING, name); data.set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString()); data.set(chargesKey, PersistentDataType.LONG, 0L); - meta.displayName(Component.text("Pocket Base Sharing Compass")); + meta.itemName(Component.text("Pocket Base Sharing Compass")); meta.setMaxStackSize(1); item.setItemMeta(meta); return withCharges(item, 0); diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index 898bf53..7f634f4 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -2,11 +2,13 @@ package games.dmg.spigotbase; import java.io.IOException; import java.time.Clock; +import java.util.ArrayList; import java.util.Map; import java.util.Objects; import java.util.logging.Level; import org.bukkit.NamespacedKey; import org.bukkit.command.PluginCommand; +import org.bukkit.inventory.Recipe; import org.bukkit.plugin.java.JavaPlugin; public final class SpigotBasePlugin extends JavaPlugin { @@ -19,6 +21,8 @@ public final class SpigotBasePlugin extends JavaPlugin { private PocketBaseController pocketBaseController; private PocketBaseKeystoneService keystoneService; private PocketBaseSharingCompass sharingCompass; + private PocketBaseRecipeRegistry recipeRegistry; + private PocketBaseRecipeDiscovery recipeDiscovery; private BaseIdentificationController identificationController; @Override @@ -54,11 +58,28 @@ public final class SpigotBasePlugin extends JavaPlugin { keystoneKey, getLogger() ); - keystoneService.registerRecipe(); sharingCompass = new PocketBaseSharingCompass(getServer(), pocketBaseManager, new NamespacedKey(this, "sharing_compass"), () -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100)); - sharingCompass.registerRecipes(); + var recipes = new ArrayList(); + recipes.add(keystoneService.recipe()); + recipes.addAll(sharingCompass.recipes()); + recipeRegistry = new PocketBaseRecipeRegistry(getServer(), recipes, getLogger()); + recipeRegistry.register(); + keystoneService.setRecipesAvailable(recipeRegistry::isAvailable); + keystoneService.setRecipeControl(recipeRegistry::controls); + sharingCompass.setRecipesAvailable(recipeRegistry::isAvailable); + sharingCompass.setRecipeControl(recipeRegistry::controls); + recipeDiscovery = new PocketBaseRecipeDiscovery( + recipeRegistry, + pocketBaseManager, + sharingCompass, + keystoneKey, + new NamespacedKey(this, "sharing_compass"), + new NamespacedKey(this, "sharing_compass_recharge"), + getLogger() + ); + sharingCompass.setRecipeDiscovery(recipeDiscovery); } catch (RuntimeException | IOException exception) { getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception); getServer().getPluginManager().disablePlugin(this); @@ -118,6 +139,8 @@ public final class SpigotBasePlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(pocketBaseController, this); getServer().getPluginManager().registerEvents(keystoneService, this); getServer().getPluginManager().registerEvents(sharingCompass, this); + getServer().getPluginManager().registerEvents(recipeDiscovery, this); + recipeDiscovery.reconcileOnline(getServer().getOnlinePlayers()); command("setbase").setExecutor(new SetBaseCommand( stateManager, baseService, Clock.systemUTC(), pocketBaseController @@ -127,7 +150,8 @@ public final class SpigotBasePlugin extends JavaPlugin { stateManager, settingsProvider, flightController, - pocketBaseManager + pocketBaseManager, + recipeDiscovery ); command("basesettings").setExecutor(settingsCommand); command("basesettings").setTabCompleter(settingsCommand); @@ -174,6 +198,9 @@ public final class SpigotBasePlugin extends JavaPlugin { if (identificationController != null) { identificationController.close(); } + if (recipeRegistry != null) { + recipeRegistry.close(); + } if (progressListener != null) { progressListener.removeAllBossBars(); } diff --git a/src/nativeTest/java/games/dmg/spigotbase/NativeRecipeBookTest.java b/src/nativeTest/java/games/dmg/spigotbase/NativeRecipeBookTest.java new file mode 100644 index 0000000..8e028c9 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotbase/NativeRecipeBookTest.java @@ -0,0 +1,57 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.*; + +import io.netty.buffer.Unpooled; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ServerboundPlaceRecipePacket; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.stats.ServerRecipeBook; +import net.minecraft.world.item.crafting.Recipe; +import net.minecraft.world.item.crafting.display.RecipeDisplayId; +import org.junit.jupiter.api.Test; + +/** Native protocol/book checks pinned to the supported Purpur runtime. */ +final class NativeRecipeBookTest { + @Test + void placeRecipePacketCodecPreservesAutofillSelectionAndMode() { + var expected = new ServerboundPlaceRecipePacket( + 17, new RecipeDisplayId(42), true); + var buffer = new FriendlyByteBuf(Unpooled.buffer()); + + ServerboundPlaceRecipePacket.STREAM_CODEC.encode(buffer, expected); + var decoded = ServerboundPlaceRecipePacket.STREAM_CODEC.decode(buffer); + + assertEquals(expected, decoded); + assertEquals(17, decoded.containerId()); + assertEquals(42, decoded.recipe().index()); + assertTrue(decoded.useMaxItems()); + assertFalse(buffer.isReadable(), "The pinned codec must consume exactly one request"); + buffer.release(); + } + + @Test + void serverRecipeBookKnowledgeIsIdempotentAndCopiesAcrossLifecycle() { + ResourceKey> vanilla = recipeKey("minecraft:wooden_pickaxe"); + ResourceKey> keystone = recipeKey("spigotbase:pocket_base_keystone"); + ServerRecipeBook book = new ServerRecipeBook((recipe, consumer) -> {}); + + book.add(vanilla); + book.add(keystone); + book.add(keystone); + assertEquals(2, book.known.size()); + assertTrue(book.contains(vanilla)); + assertTrue(book.contains(keystone)); + + ServerRecipeBook reenabled = new ServerRecipeBook((recipe, consumer) -> {}); + reenabled.copyOverData(book); + assertTrue(reenabled.contains(vanilla)); + assertTrue(reenabled.contains(keystone)); + } + + private static ResourceKey> recipeKey(String value) { + return ResourceKey.create(Registries.RECIPE, Identifier.parse(value)); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java b/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java index dfaaaa5..c17bf32 100644 --- a/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java +++ b/src/nativeTest/java/games/dmg/spigotbase/SharingCompassEventsTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.*; import java.util.*; import java.util.function.IntSupplier; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.*; import org.bukkit.craftbukkit.inventory.CraftItemFactory; import org.bukkit.craftbukkit.util.CraftMagicNumbers; @@ -35,6 +36,7 @@ class SharingCompassEventsTest { ItemStack[] matrix = new ItemStack[9]; ItemStack result; ItemStack cursor; + Recipe candidateOverride; final PlayerInventory carried = mock(PlayerInventory.class); final ItemStack[] contents = new ItemStack[41]; MockedStatic bukkit; @@ -84,6 +86,66 @@ class SharingCompassEventsTest { assertEquals(Material.END_CRYSTAL, shaped.getIngredientMap().get('C').getType()); } + @Test void recipeBookPresentsEquipmentWithAuthenticatedKeystoneAndRepresentativeCompasses() { + service.registerRecipes(); + var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class); + verify(server, times(2)).addRecipe(recipes.capture()); + var creation = recipes.getAllValues().stream() + .map(ShapedRecipe.class::cast).filter(recipe -> recipe.getKey().equals(KEY)) + .findFirst().orElseThrow(); + var recharge = recipes.getAllValues().stream() + .map(ShapedRecipe.class::cast).filter(recipe -> !recipe.getKey().equals(KEY)) + .findFirst().orElseThrow(); + var keystoneKey = field("keystone"); + var keystone = PocketBaseKeystoneService.recipe( + keystoneKey, PocketBaseKeystoneService.createKeystone(keystoneKey)); + + assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.EQUIPMENT, keystone.getCategory()); + assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.EQUIPMENT, creation.getCategory()); + assertEquals(org.bukkit.inventory.recipe.CraftingBookCategory.EQUIPMENT, recharge.getCategory()); + assertTrue(PocketBaseKeystoneService.isKeystone(keystone.getResult(), keystoneKey)); + for (var representative : List.of(creation.getResult(), recharge.getResult())) { + assertEquals(Material.COMPASS, representative.getType()); + assertFalse(representative.getItemMeta().getPersistentDataContainer().has(KEY), + "A recipe-book representative must never authenticate a Sharing Compass"); + assertTrue(PlainTextComponentSerializer.plainText().serialize( + representative.getItemMeta().displayName()).contains("Representative")); + } + var center = assertInstanceOf(RecipeChoice.MaterialChoice.class, + recharge.getChoiceMap().get('C')); + assertEquals(List.of(Material.COMPASS), center.getChoices()); + } + + @Test void registryMatchesPurpurItemTypeChoicesToConfiguredMaterialChoices() { + NamespacedKey firstKey = field("runtime_choice"); + ShapedRecipe definition = new ShapedRecipe(firstKey, new ItemStack(Material.STONE)); + definition.shape("A"); + definition.setIngredient('A', new RecipeChoice.MaterialChoice(Material.STONE)); + ShapedRecipe activeCopy = new ShapedRecipe(firstKey, new ItemStack(Material.STONE)); + activeCopy.shape("x"); + activeCopy.setIngredient('x', RecipeChoice.itemType(org.bukkit.inventory.ItemType.STONE)); + RecipeChoice runtimeChoice = activeCopy.getChoiceMap().get('x'); + assertInstanceOf(RecipeChoice.ItemTypeChoice.class, runtimeChoice); + Recipe second = mock(Recipe.class, withSettings().extraInterfaces(Keyed.class)); + Recipe third = mock(Recipe.class, withSettings().extraInterfaces(Keyed.class)); + NamespacedKey secondKey = field("runtime_choice_second"); + NamespacedKey thirdKey = field("runtime_choice_third"); + when(((Keyed) second).getKey()).thenReturn(secondKey); + when(((Keyed) third).getKey()).thenReturn(thirdKey); + when(server.addRecipe(any())).thenReturn(true); + when(server.getRecipe(firstKey)).thenReturn(activeCopy); + when(server.getRecipe(secondKey)).thenReturn(second); + when(server.getRecipe(thirdKey)).thenReturn(third); + var registry = new PocketBaseRecipeRegistry( + server, List.of(definition, second, third), + java.util.logging.Logger.getAnonymousLogger()); + activeCopy.setGroup(definition.getGroup()); + assertTrue(registry.register()); + + assertTrue(registry.isAvailable()); + assertTrue(registry.controls(activeCopy)); + } + static NamespacedKey field(String suffix) { return new NamespacedKey("spigotbase", "sharing_compass_" + suffix); } static long charges(ItemStack item) { return item.getItemMeta().getPersistentDataContainer().get(field("charges"), PersistentDataType.LONG); } ItemStack compass(long charges) throws Exception { @@ -107,6 +169,282 @@ class SharingCompassEventsTest { when(world.getBlockAt(any(Location.class))).thenReturn(table); } + @Test void registeredDiscoveryAndPlacedRecipePathCommitsCreationThenRecharge() throws Exception { + var keystoneKey = field("keystone"); + var recipes = new ArrayList(); + recipes.add(PocketBaseKeystoneService.recipe( + keystoneKey, PocketBaseKeystoneService.createKeystone(keystoneKey))); + recipes.addAll(service.recipes()); + when(server.addRecipe(any())).thenReturn(true); + when(server.getRecipe(any())).thenAnswer(invocation -> recipes.stream() + .filter(recipe -> recipe instanceof Keyed keyed + && keyed.getKey().equals(invocation.getArgument(0))) + .findFirst().orElse(null)); + when(server.removeRecipe(any())).thenReturn(true); + var registry = new PocketBaseRecipeRegistry( + server, recipes, java.util.logging.Logger.getAnonymousLogger()); + assertTrue(registry.register()); + service.setRecipesAvailable(registry::isAvailable); + service.setRecipeControl(registry::controls); + Set known = new HashSet<>(); + when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0))); + when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0))); + when(carried.getStorageContents()).thenReturn(new ItemStack[0]); + when(carried.getItemInOffHand()).thenReturn(null); + var discovery = new PocketBaseRecipeDiscovery( + registry, pockets, service, keystoneKey, KEY, field("recharge"), + java.util.logging.Logger.getAnonymousLogger()); + service.setRecipeDiscovery(discovery); + + discovery.reconcile(player); + assertEquals(Set.of(keystoneKey, KEY), known); + candidateOverride = recipes.get(1); + creationMatrix(); + preview(); + dispatch(take(ClickType.LEFT)); + ItemStack created = cursor; + assertNotNull(created); + assertEquals(Set.of(keystoneKey, KEY, field("recharge")), known); + + cursor = null; + candidateOverride = recipes.get(2); + rechargeMatrix(created); + preview(); + dispatch(take(ClickType.LEFT)); + assertEquals(8, charges(cursor)); + registry.close(); + verify(server).removeRecipe(keystoneKey); + verify(server).removeRecipe(KEY); + verify(server).removeRecipe(field("recharge")); + } + + @Test void reconciliationRecognizesATransferredNativeCompassInMainStorage() throws Exception { + ItemStack authentic = compass(0); + UUID guest = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(guest); + when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest)); + when(carried.getStorageContents()).thenReturn(new ItemStack[] {authentic}); + when(carried.getItemInOffHand()).thenReturn(null); + var registry = mock(PocketBaseRecipeRegistry.class); + when(registry.isAvailable()).thenReturn(true); + Set known = new HashSet<>(); + when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0))); + when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0))); + var discovery = new PocketBaseRecipeDiscovery( + registry, pockets, service, field("keystone"), KEY, field("recharge"), + java.util.logging.Logger.getAnonymousLogger()); + + discovery.reconcile(player); + + assertEquals(Set.of(field("recharge")), known); + } + + @Test void newSharingCompassesUseItemNameWithoutCustomName() throws Exception { + ItemStack authentic = compass(0); + var meta = authentic.getItemMeta(); + var nativeItem = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(authentic); + + assertTrue(meta.hasItemName()); + assertEquals(net.kyori.adventure.text.Component.text("Pocket Base Sharing Compass"), meta.itemName()); + assertFalse(meta.hasCustomName()); + assertTrue(nativeItem.has(net.minecraft.core.component.DataComponents.ITEM_NAME)); + assertFalse(nativeItem.has(net.minecraft.core.component.DataComponents.CUSTOM_NAME)); + } + + @Test void authenticatedCustomNamesMigrateIdempotentlyWithoutMetadataLoss() throws Exception { + ItemStack legacy = compass(17); + var legacyMeta = legacy.getItemMeta(); + var anvilTitle = net.kyori.adventure.text.Component.text("Alice's irreplaceable compass"); + var unknownKey = new NamespacedKey("other-plugin", "opaque"); + legacyMeta.customName(anvilTitle); + legacyMeta.getPersistentDataContainer().set( + unknownKey, PersistentDataType.STRING, "preserve-me"); + legacyMeta.setEnchantmentGlintOverride(true); + legacy.setItemMeta(legacyMeta); + ItemStack original = legacy.clone(); + ItemStack expected = legacy.clone(); + var expectedMeta = expected.getItemMeta(); + expectedMeta.itemName(anvilTitle); + expectedMeta.customName(null); + expected.setItemMeta(expectedMeta); + + ItemStack migrated = service.normalizeAuthentic(legacy); + + assertNotSame(legacy, migrated, "Migration must clone instead of mutating the source stack"); + assertEquals(original, legacy, "The source stack must remain unchanged"); + assertEquals(expected, migrated, "Only CUSTOM_NAME may move to ITEM_NAME"); + assertEquals("preserve-me", migrated.getItemMeta().getPersistentDataContainer().get( + unknownKey, PersistentDataType.STRING)); + assertEquals(17, charges(migrated)); + assertSame(migrated, service.normalizeAuthentic(migrated), + "Already-normalized items must be stable without replacement"); + } + + @Test void reconciliationNormalizesOnlyAuthenticStorageAndOffhandCompasses() throws Exception { + ItemStack storedLegacy = compass(3); + var storedMeta = storedLegacy.getItemMeta(); + storedMeta.customName(net.kyori.adventure.text.Component.text("Stored anvil title")); + storedLegacy.setItemMeta(storedMeta); + ItemStack offhandLegacy = storedLegacy.clone(); + var offhandMeta = offhandLegacy.getItemMeta(); + offhandMeta.customName(net.kyori.adventure.text.Component.text("Offhand anvil title")); + offhandLegacy.setItemMeta(offhandMeta); + ItemStack ordinary = new ItemStack(Material.COMPASS); + var ordinaryMeta = ordinary.getItemMeta(); + ordinaryMeta.customName(net.kyori.adventure.text.Component.text("Ordinary named compass")); + ordinary.setItemMeta(ordinaryMeta); + ItemStack malformed = storedLegacy.clone(); + var malformedMeta = malformed.getItemMeta(); + malformedMeta.getPersistentDataContainer().remove(field("id")); + malformed.setItemMeta(malformedMeta); + contents[0] = storedLegacy; + contents[1] = ordinary; + contents[2] = malformed; + contents[40] = offhandLegacy; + when(carried.getStorageContents()).thenAnswer(invocation -> Arrays.copyOf(contents, 36)); + when(carried.getItemInOffHand()).thenAnswer(invocation -> contents[40]); + var registry = mock(PocketBaseRecipeRegistry.class); + when(registry.isAvailable()).thenReturn(true); + when(player.hasDiscoveredRecipe(any())).thenReturn(true); + var discovery = new PocketBaseRecipeDiscovery( + registry, pockets, service, field("keystone"), KEY, field("recharge"), + java.util.logging.Logger.getAnonymousLogger()); + + discovery.reconcile(player); + ItemStack normalizedStored = contents[0]; + ItemStack normalizedOffhand = contents[40]; + discovery.reconcile(player); + + assertNotSame(storedLegacy, normalizedStored); + assertFalse(normalizedStored.getItemMeta().hasCustomName()); + assertEquals(net.kyori.adventure.text.Component.text("Stored anvil title"), + normalizedStored.getItemMeta().itemName()); + assertNotSame(offhandLegacy, normalizedOffhand); + assertFalse(normalizedOffhand.getItemMeta().hasCustomName()); + assertEquals(net.kyori.adventure.text.Component.text("Offhand anvil title"), + normalizedOffhand.getItemMeta().itemName()); + assertSame(normalizedStored, contents[0], "Reconciliation must be idempotent"); + assertSame(normalizedOffhand, contents[40], "Offhand reconciliation must be idempotent"); + assertSame(ordinary, contents[1], "Ordinary named compasses must never change"); + assertSame(malformed, contents[2], "Malformed marked compasses must never change"); + } + + @Test void rechargeRecipeClickNormalizesOnlyAuthenticMainStorageBeforeNativePlacement() throws Exception { + ItemStack storedLegacy = compass(0); + var storedMeta = storedLegacy.getItemMeta(); + storedMeta.customName(net.kyori.adventure.text.Component.text("Autofill title")); + storedLegacy.setItemMeta(storedMeta); + ItemStack ordinary = new ItemStack(Material.COMPASS); + var ordinaryMeta = ordinary.getItemMeta(); + ordinaryMeta.customName(net.kyori.adventure.text.Component.text("Ordinary title")); + ordinary.setItemMeta(ordinaryMeta); + ItemStack malformed = storedLegacy.clone(); + var malformedMeta = malformed.getItemMeta(); + malformedMeta.getPersistentDataContainer().remove(field("owner")); + malformed.setItemMeta(malformedMeta); + ItemStack offhandLegacy = storedLegacy.clone(); + contents[0] = storedLegacy; + contents[1] = ordinary; + contents[2] = malformed; + contents[40] = offhandLegacy; + when(carried.getStorageContents()).thenAnswer(invocation -> Arrays.copyOf(contents, 36)); + Recipe recharge = service.recipes().get(1); + + dispatch(new org.bukkit.event.player.PlayerRecipeBookClickEvent(player, recharge, false)); + + assertNotSame(storedLegacy, contents[0]); + assertFalse(contents[0].getItemMeta().hasCustomName()); + assertEquals(net.kyori.adventure.text.Component.text("Autofill title"), + contents[0].getItemMeta().itemName()); + assertSame(ordinary, contents[1], "An ordinary named Compass must never be migrated"); + assertSame(malformed, contents[2], "A malformed marked Compass must never be migrated"); + assertSame(offhandLegacy, contents[40], "Offhand is discovery-only for native autofill"); + } + + @Test void nativeServerPlaceRecipeRelocatesMigratedAuthenticLegacyCompass() throws Exception { + ItemStack legacy = compass(5); + var legacyMeta = legacy.getItemMeta(); + var title = net.kyori.adventure.text.Component.text("Native autofill legacy title"); + var unknownKey = new NamespacedKey("other-plugin", "native-autofill-data"); + legacyMeta.itemName(null); + legacyMeta.customName(title); + legacyMeta.getPersistentDataContainer().set( + unknownKey, PersistentDataType.STRING, "still-here"); + legacy.setItemMeta(legacyMeta); + contents[0] = legacy; + when(carried.getStorageContents()).thenAnswer(invocation -> Arrays.copyOf(contents, 36)); + assertFalse(net.minecraft.world.entity.player.Inventory.isUsableForCrafting( + org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(legacy)), + "Pinned Purpur must reject the legacy CUSTOM_NAME before migration"); + Recipe recharge = service.recipes().get(1); + dispatch(new org.bukkit.event.player.PlayerRecipeBookClickEvent(player, recharge, false)); + ItemStack normalized = contents[0]; + assertFalse(normalized.getItemMeta().hasCustomName()); + assertTrue(net.minecraft.world.entity.player.Inventory.isUsableForCrafting( + org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(normalized)), + "ITEM_NAME migration must make the authentic item natively placeable"); + + var nativePlayer = mock(net.minecraft.world.entity.player.Player.class); + var nativeInventory = new net.minecraft.world.entity.player.Inventory( + nativePlayer, new net.minecraft.world.entity.EntityEquipment()); + nativeInventory.setItem(0, + org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(normalized)); + nativeInventory.setItem(1, new net.minecraft.world.item.ItemStack( + net.minecraft.world.item.Items.ENDER_PEARL, 8)); + var grid = new net.minecraft.world.SimpleContainer(9); + var slots = new ArrayList(); + for (int slot = 0; slot < 9; slot++) { + slots.add(new net.minecraft.world.inventory.Slot(grid, slot, 0, 0)); + } + var pattern = net.minecraft.world.item.crafting.ShapedRecipePattern.of(Map.of( + 'P', net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.ENDER_PEARL), + 'C', net.minecraft.world.item.crafting.Ingredient.of(net.minecraft.world.item.Items.COMPASS) + ), "PPP", "PCP", "PPP"); + var nativeRecipe = new net.minecraft.world.item.crafting.ShapedRecipe( + new net.minecraft.world.item.crafting.Recipe.CommonInfo(true), + new net.minecraft.world.item.crafting.CraftingRecipe.CraftingBookInfo( + net.minecraft.world.item.crafting.CraftingBookCategory.EQUIPMENT, ""), + pattern, + new net.minecraft.world.item.ItemStackTemplate(net.minecraft.world.item.Items.COMPASS)); + var recipeKey = net.minecraft.resources.ResourceKey.create( + net.minecraft.core.registries.Registries.RECIPE, + net.minecraft.resources.Identifier.parse("spigotbase:sharing_compass_recharge")); + var holder = new net.minecraft.world.item.crafting.RecipeHolder<>(recipeKey, nativeRecipe); + var access = new net.minecraft.recipebook.ServerPlaceRecipe.CraftingMenuAccess< + net.minecraft.world.item.crafting.ShapedRecipe>() { + @Override public void fillCraftSlotsStackedContents( + net.minecraft.world.entity.player.StackedItemContents contents) { + grid.fillStackedContents(contents); + } + @Override public void clearCraftingContent() { + grid.clearContent(); + } + @Override public boolean recipeMatches( + net.minecraft.world.item.crafting.RecipeHolder< + net.minecraft.world.item.crafting.ShapedRecipe> ignored) { + return false; + } + }; + + var action = net.minecraft.recipebook.ServerPlaceRecipe.placeRecipe( + access, 3, 3, slots, slots, nativeInventory, holder, false, false); + + assertEquals(net.minecraft.world.inventory.RecipeBookMenu.PostPlaceAction.NOTHING, action); + ItemStack placed = org.bukkit.craftbukkit.inventory.CraftItemStack.asBukkitCopy(grid.getItem(4)); + assertEquals(normalized, placed, "Native placement must move the authentic item intact"); + assertEquals(title, placed.getItemMeta().itemName()); + assertEquals("still-here", placed.getItemMeta().getPersistentDataContainer().get( + unknownKey, PersistentDataType.STRING)); + assertEquals(5, charges(placed)); + assertTrue(nativeInventory.getItem(0).isEmpty()); + for (int slot = 0; slot < 9; slot++) { + if (slot != 4) { + assertTrue(grid.getItem(slot).is(net.minecraft.world.item.Items.ENDER_PEARL)); + assertEquals(1, grid.getItem(slot).getCount()); + } + } + } + @Test void safeRechargePreviewAndCommitRequireExactPearlsAndBoundTableWorld() throws Exception { var item = compass(0); rechargeMatrix(item); @@ -139,6 +477,30 @@ class SharingCompassEventsTest { verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean()); } + @Test void legacyRechargePreviewAndCommitNormalizeNameAndPreserveUnknownMetadata() throws Exception { + ItemStack legacy = compass(0); + var meta = legacy.getItemMeta(); + var title = net.kyori.adventure.text.Component.text("Recharge-path title"); + var unknownKey = new NamespacedKey("other-plugin", "recharge-data"); + meta.customName(title); + meta.getPersistentDataContainer().set(unknownKey, PersistentDataType.INTEGER, 73); + legacy.setItemMeta(meta); + rechargeMatrix(legacy); + + preview(); + assertFalse(result.getItemMeta().hasCustomName()); + assertEquals(title, result.getItemMeta().itemName()); + assertEquals(73, result.getItemMeta().getPersistentDataContainer().get( + unknownKey, PersistentDataType.INTEGER)); + dispatch(take(ClickType.LEFT)); + + assertFalse(cursor.getItemMeta().hasCustomName()); + assertEquals(title, cursor.getItemMeta().itemName()); + assertEquals(73, cursor.getItemMeta().getPersistentDataContainer().get( + unknownKey, PersistentDataType.INTEGER)); + assertEquals(8, charges(cursor)); + } + static java.util.stream.Stream rechargeBoundaries() { var cases = new ArrayList(); long[] totals = {8, 9, 16, 17, 24, 25, 32, 33, 40, 41, 48, 49, 56, 57, 64, 65, 72, 73, 80, 81, 1024, Long.MAX_VALUE}; @@ -205,6 +567,8 @@ class SharingCompassEventsTest { @org.junit.jupiter.params.provider.EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"}) void transferredCompassEntersBoundOwnerOnlyConsumesAfterSuccessAndNeverReturns(Action action) throws Exception { var item = compass(2); + PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class); + service.setRecipeDiscovery(discovery); UUID guest = UUID.randomUUID(); when(player.getUniqueId()).thenReturn(guest); when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest)); @@ -225,14 +589,36 @@ class SharingCompassEventsTest { dispatch(use(action, EquipmentSlot.HAND)); assertEquals(2, charges(contents[0])); verify(player).setFallDistance(12F); + verifyNoInteractions(discovery); successfulTeleport(destination); dispatch(use(action, EquipmentSlot.HAND)); assertEquals(1, charges(contents[0]), "Guest travel must spend exactly one charge"); + verify(discovery).discoverRecharge(player); assertEquals(owner.toString(), contents[0].getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING)); when(player.getWorld()).thenReturn(destinationWorld); dispatch(use(action, EquipmentSlot.HAND)); assertEquals(1, charges(contents[0])); verify(player, times(2)).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); + verify(discovery).discoverRecharge(player); + } + + @Test void authenticCompassUseNormalizesAnvilNameBeforeAnyTravelDecision() throws Exception { + ItemStack legacy = compass(0); + var meta = legacy.getItemMeta(); + var title = net.kyori.adventure.text.Component.text("Use-path title"); + meta.customName(title); + legacy.setItemMeta(meta); + contents[0] = legacy; + + var event = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND); + dispatch(event); + + assertTrue(event.isCancelled()); + assertNotSame(legacy, contents[0]); + assertFalse(contents[0].getItemMeta().hasCustomName()); + assertEquals(title, contents[0].getItemMeta().itemName()); + assertEquals(0, charges(contents[0])); + verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)); } @Test void unavailableEmptyAndDeniedTravelNeverSpendAndOffHandCanEnter() throws Exception { @@ -383,6 +769,37 @@ class SharingCompassEventsTest { assertEquals(16, matrix[0].getAmount()); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"ordinary", "name-only", "stale"}) + void autofilledUnauthorizedCompassMaterialsNeverAuthorizeRecharge(String kind) throws Exception { + ItemStack item; + if (kind.equals("stale")) { + item = compass(0); + when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner)); + } else { + item = new ItemStack(Material.COMPASS); + if (kind.equals("name-only")) { + var meta = item.getItemMeta(); + meta.displayName(net.kyori.adventure.text.Component.text("Pocket Base Sharing Compass")); + item.setItemMeta(meta); + } + } + rechargeMatrix(item); + + preview(); + assertNull(result); + var event = take(ClickType.LEFT); + dispatch(event); + + assertTrue(event.isCancelled(), "The owned material-level recipe must fail closed"); + assertSame(item, matrix[4]); + assertEquals(16, matrix[0].getAmount()); + assertNull(cursor); + verifyNoInteractions(roll); + verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), + anyBoolean(), anyBoolean(), anyBoolean()); + } + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(longs = {Long.MAX_VALUE, Long.MAX_VALUE - 7}) void numericOverflowRefusesRechargeBeforeRollingOrPaying(long charges) throws Exception { @@ -711,6 +1128,9 @@ class SharingCompassEventsTest { } } ShapedRecipe candidateRecipe() { + if (candidateOverride != null) { + return (ShapedRecipe) candidateOverride; + } if (matrix[4] != null && matrix[4].getType() == Material.COMPASS && matrix[0] != null) { if (matrix[0].getType() == Material.PAPER) { return new ShapedRecipe(NamespacedKey.minecraft("map"), new ItemStack(Material.MAP)); @@ -762,6 +1182,54 @@ class SharingCompassEventsTest { verifyNoInteractions(roll); } + @Test void residualOwnedRecipeFailsClosedWhenIncompleteCleanupCannotRemoveIt() throws Exception { + ShapedRecipe creation = service.recipes().getFirst(); + Recipe conflict = PocketBaseKeystoneService.recipe( + field("keystone"), PocketBaseKeystoneService.createKeystone(field("keystone"))); + Recipe unattempted = service.recipes().get(1); + when(server.addRecipe(creation)).thenReturn(true); + when(server.addRecipe(conflict)).thenReturn(false); + when(server.getRecipe(KEY)).thenReturn(creation); + when(server.removeRecipe(KEY)).thenReturn(false); + var registry = new PocketBaseRecipeRegistry( + server, List.of(creation, conflict, unattempted), + java.util.logging.Logger.getAnonymousLogger()); + assertFalse(registry.register()); + service.setRecipesAvailable(registry::isAvailable); + service.setRecipeControl(registry::controls); + creationMatrix(); + when(inventory.getRecipe()).thenReturn(creation); + result = creation.getResult(); + + dispatch(new PrepareItemCraftEvent(inventory, view, false)); + assertNull(result, "A residual owned recipe must have no craftable preview"); + result = creation.getResult(); + var craft = new CraftItemEvent(creation, view, InventoryType.SlotType.RESULT, + 0, ClickType.LEFT, InventoryAction.PICKUP_ALL); + dispatch(craft); + + assertTrue(craft.isCancelled(), "A residual owned recipe must not fall through to vanilla"); + assertNull(result); + assertNotNull(matrix[4]); + verifyNoInteractions(roll); + } + + @Test void incompleteRegistrationNeverInterceptsAConflictingRecipeKey() throws Exception { + service.setRecipesAvailable(() -> false); + service.setRecipeControl(ignored -> false); + creationMatrix(); + result = candidateRecipe().getResult(); + + preview(); + assertNotNull(result, "The conflicting recipe result must remain untouched"); + var event = take(ClickType.LEFT); + dispatch(event); + assertFalse(event.isCancelled()); + assertNull(cursor); + assertNotNull(matrix[4]); + verifyNoInteractions(roll); + } + @Test void foreignRecipeWithSameIngredientsIsNotReplacedOrManuallyCrafted() throws Exception { creationMatrix(); var foreign = new ShapedRecipe(new NamespacedKey("other", "recipe"), new ItemStack(Material.DIAMOND)); @@ -777,6 +1245,24 @@ class SharingCompassEventsTest { assertNotNull(matrix[4]); } + @Test void rechargeDiscoveryFollowsOnlyCommittedCompassCreation() throws Exception { + PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class); + service.setRecipeDiscovery(discovery); + creationMatrix(); + + preview(); + verifyNoInteractions(discovery); + var cancelled = take(ClickType.LEFT); + cancelled.setCancelled(true); + dispatch(cancelled); + verifyNoInteractions(discovery); + var committed = take(ClickType.LEFT); + dispatch(committed); + verify(discovery).discoverRecharge(player); + dispatch(committed); + verify(discovery).discoverRecharge(player); + } + @Test void takingCreationPaysOnceBindsCrafterAndRejectsLockedAndCancelledCrafts() throws Exception { creationMatrix(); preview(); diff --git a/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java b/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java index f10f64b..6b1deb3 100644 --- a/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseIdentificationWiringTest.java @@ -4,11 +4,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.logging.Logger; import org.bukkit.Server; import org.bukkit.command.PluginCommand; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ShapedRecipe; import org.bukkit.plugin.PluginManager; import org.bukkit.scheduler.BukkitScheduler; import org.junit.jupiter.api.Test; @@ -36,17 +38,37 @@ class BaseIdentificationWiringTest { doReturn(List.of()).when(server).getOnlinePlayers(); doCallRealMethod().when(plugin).onEnable(); doCallRealMethod().when(plugin).onDisable(); + var settingsArguments = new ArrayList>(); try (var validator = mockStatic(PluginSettingsValidator.class); - var keystones = mockConstruction(PocketBaseKeystoneService.class); - var sharing = mockConstruction(PocketBaseSharingCompass.class); + var keystones = mockConstruction(PocketBaseKeystoneService.class, + (service, context) -> when(service.recipe()).thenReturn(mock(ShapedRecipe.class))); + var sharing = mockConstruction(PocketBaseSharingCompass.class, + (service, context) -> when(service.recipes()).thenReturn( + List.of(mock(ShapedRecipe.class), mock(ShapedRecipe.class)))); + var registries = mockConstruction(PocketBaseRecipeRegistry.class, + (registry, context) -> when(registry.register()).thenReturn(true)); + var discoveries = mockConstruction(PocketBaseRecipeDiscovery.class); + var settingsCommands = mockConstruction(BaseSettingsCommand.class, + (command, context) -> settingsArguments.add(context.arguments())); var packets = mockConstruction(NativeBaseLabelPackets.class); var labels = mockConstruction(BukkitBaseLabels.class)) { validator.when(() -> PluginSettingsValidator.validateMaterials(any())).thenAnswer(call -> call.getArgument(0)); plugin.onEnable(); assertEquals(1, keystones.constructed().size()); assertEquals(1, sharing.constructed().size(), "Enable must install guest compasses alongside Keystones"); - verify(sharing.constructed().getFirst()).registerRecipes(); + assertEquals(1, registries.constructed().size()); + assertEquals(1, discoveries.constructed().size()); + assertEquals(1, settingsCommands.constructed().size()); + verify(registries.constructed().getFirst()).register(); + verify(keystones.constructed().getFirst()).setRecipesAvailable(any()); + verify(keystones.constructed().getFirst()).setRecipeControl(any()); + verify(sharing.constructed().getFirst()).setRecipesAvailable(any()); + verify(sharing.constructed().getFirst()).setRecipeControl(any()); + verify(sharing.constructed().getFirst()).setRecipeDiscovery(discoveries.constructed().getFirst()); + verify(discoveries.constructed().getFirst()).reconcileOnline(server.getOnlinePlayers()); + verify(manager).registerEvents(discoveries.constructed().getFirst(), plugin); verify(manager).registerEvents(sharing.constructed().getFirst(), plugin); + assertSame(discoveries.constructed().getFirst(), settingsArguments.getFirst().get(4)); verify(manager, never()).disablePlugin(plugin); var tasks = ArgumentCaptor.forClass(Runnable.class); verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong()); @@ -55,6 +77,7 @@ class BaseIdentificationWiringTest { verify(manager).registerEvents((BaseIdentificationController) identification.getFirst(), plugin); identification.getFirst().run(); plugin.onDisable(); + verify(registries.constructed().getFirst()).close(); assertEquals(1, packets.constructed().size()); verify(labels.constructed().getFirst()).forgetAll(); } diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java index 966b541..2d2916b 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -256,17 +256,20 @@ final class BaseSettingsCommandTest { when(pocketBases.upgrade(playerId)).thenReturn( new PocketBaseState(playerId, 1, java.util.Optional.empty()) ); + PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class); BaseSettingsCommand command = new BaseSettingsCommand( stateManager, new PluginSettingsProvider(PluginSettings.from(Map.of())), mock(BaseFlightController.class), - pocketBases + pocketBases, + discovery ); command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"}); verify(pocketBases).upgrade(playerId); verify(inventory).setStorageContents(any(ItemStack[].class)); + verify(discovery).discoverCreation(player); } @Test @@ -454,15 +457,18 @@ final class BaseSettingsCommandTest { PocketBaseManager pocketBases = mock(PocketBaseManager.class); when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId)); when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed")); + PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class); BaseSettingsCommand command = new BaseSettingsCommand( stateManager, new PluginSettingsProvider(PluginSettings.from(Map.of())), mock(BaseFlightController.class), - pocketBases + pocketBases, + discovery ); command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"}); + verify(discovery, never()).discoverCreation(player); ArgumentCaptor contents = ArgumentCaptor.forClass(ItemStack[].class); verify(inventory, times(2)).setStorageContents(contents.capture()); ItemStack[] restored = contents.getAllValues().get(1); diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java index 801f97d..8e92f7d 100644 --- a/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java +++ b/src/test/java/games/dmg/spigotbase/PocketBaseKeystoneServiceTest.java @@ -99,6 +99,36 @@ final class PocketBaseKeystoneServiceTest { verify(inventory).setResult(null); } + @Test + void residualOwnedKeystoneRecipeFailsClosedWhenRegistrationGroupIsUnavailable() { + NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class); + InventoryView view = mock(InventoryView.class); + CraftingInventory inventory = mock(CraftingInventory.class); + ShapedRecipe recipe = mock(ShapedRecipe.class); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + PocketBaseKeystoneService service = new PocketBaseKeystoneService( + mock(Server.class), pocketBases, mock(KeystoneReturnStore.class), + key, Logger.getAnonymousLogger() + ); + service.setRecipesAvailable(() -> false); + service.setRecipeControl(candidate -> true); + when(event.getView()).thenReturn(view); + when(view.getPlayer()).thenReturn(player); + when(event.getInventory()).thenReturn(inventory); + when(event.getRecipe()).thenReturn(recipe); + when(player.getUniqueId()).thenReturn(playerId); + when(pocketBases.state(playerId)).thenReturn( + new PocketBaseState(playerId, 1, Optional.empty()) + ); + + service.onPrepareCraft(event); + + verify(inventory).setResult(null); + } + @Test void lockedPlayerCannotUseATransferredKeystone() throws Exception { NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseRecipeDiscoveryTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseRecipeDiscoveryTest.java new file mode 100644 index 0000000..2a241a6 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketBaseRecipeDiscoveryTest.java @@ -0,0 +1,170 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; + +final class PocketBaseRecipeDiscoveryTest { + private static final NamespacedKey KEYSTONE = NamespacedKey.minecraft("keystone"); + private static final NamespacedKey CREATION = NamespacedKey.minecraft("sharing"); + private static final NamespacedKey RECHARGE = NamespacedKey.minecraft("sharing_recharge"); + + @Test + void onlineReconciliationIsolatesPlayersAndRetriesMissingPostconditions() { + Player broken = mock(Player.class); + Player retrying = mock(Player.class); + UUID brokenId = UUID.randomUUID(); + UUID retryingId = UUID.randomUUID(); + PocketBaseManager pockets = mock(PocketBaseManager.class); + PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class); + PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class); + when(registry.isAvailable()).thenReturn(true); + when(broken.getUniqueId()).thenReturn(brokenId); + when(retrying.getUniqueId()).thenReturn(retryingId); + when(pockets.state(brokenId)).thenReturn(new PocketBaseState(brokenId, 1, Optional.empty())); + when(pockets.state(retryingId)).thenReturn(new PocketBaseState(retryingId, 1, Optional.empty())); + when(broken.hasDiscoveredRecipe(any())).thenThrow(new IllegalStateException("broken book")); + when(retrying.hasDiscoveredRecipe(any())).thenReturn(false); + PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery( + registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE, + Logger.getAnonymousLogger()); + + discovery.reconcileOnline(List.of(broken, retrying)); + discovery.reconcileOnline(List.of(retrying)); + + verify(retrying, times(2)).discoverRecipe(KEYSTONE); + verify(retrying, times(2)).discoverRecipe(CREATION); + } + + @Test + void completeCharacterResetPreservesPocketAndVanillaRecipeKnowledge() { + UUID playerId = UUID.randomUUID(); + NamespacedKey vanilla = NamespacedKey.minecraft("wooden_pickaxe"); + Set known = new HashSet<>(Set.of(vanilla, KEYSTONE, CREATION, RECHARGE)); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + CommandSender administrator = mock(CommandSender.class); + BaseStateManager states = mock(BaseStateManager.class); + PocketBaseManager pockets = mock(PocketBaseManager.class); + PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class); + PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class); + PlayerState character = PlayerState.newPlayer(playerId, "Builder"); + when(administrator.hasPermission("spigotbase.admin")).thenReturn(true); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Builder"); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getStorageContents()).thenReturn(new ItemStack[0]); + when(inventory.getItemInOffHand()).thenReturn(null); + when(states.player(playerId, "Builder")).thenReturn(character); + when(pockets.state(playerId)).thenReturn(new PocketBaseState(playerId, 1, Optional.empty())); + when(registry.isAvailable()).thenReturn(true); + when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0))); + BaseAdminCommand reset = new BaseAdminCommand( + mock(JavaPlugin.class), states, mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(java.util.Map.of()))); + PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery( + registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE, + Logger.getAnonymousLogger()); + + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(player); + reset.onCommand(administrator, null, "baseadmin", + new String[] {"reset", "Builder", "all", "confirm"}); + } + discovery.reconcile(player); + + assertEquals(Set.of(vanilla, KEYSTONE, CREATION, RECHARGE), known); + verify(player, never()).undiscoverRecipe(any()); + verify(player, never()).undiscoverRecipes(any()); + verify(player, never()).discoverRecipe(any()); + verify(pockets).state(playerId); + } + + @Test + void joinReconciliationUsesOnlyStorageHotbarAndOffhandForAuthenticPossession() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + Inventory enderChest = mock(Inventory.class); + ItemStack authentic = mock(ItemStack.class); + ItemStack nameOnly = mock(ItemStack.class); + PocketBaseManager pockets = mock(PocketBaseManager.class); + PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class); + PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class); + Set known = new HashSet<>(); + when(registry.isAvailable()).thenReturn(true); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getInventory()).thenReturn(inventory); + when(player.getEnderChest()).thenReturn(enderChest); + when(player.getItemOnCursor()).thenReturn(authentic); + when(inventory.getArmorContents()).thenReturn(new ItemStack[] {authentic}); + when(enderChest.getContents()).thenReturn(new ItemStack[] {authentic}); + when(inventory.getStorageContents()).thenReturn(new ItemStack[] {nameOnly}); + when(inventory.getItemInOffHand()).thenReturn(null, authentic); + when(sharing.isAuthentic(authentic)).thenReturn(true); + when(sharing.isAuthentic(nameOnly)).thenReturn(false); + when(pockets.state(playerId)).thenReturn(PocketBaseState.locked(playerId)); + when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0))); + when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0))); + PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery( + registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE, + Logger.getAnonymousLogger()); + + discovery.onJoin(new PlayerJoinEvent(player, "joined")); + assertTrue(known.isEmpty(), "Armor, cursor, Ender Chest and name-only items are excluded"); + discovery.onJoin(new PlayerJoinEvent(player, "joined again")); + assertEquals(Set.of(RECHARGE), known, "An authentic offhand compass earns recharge knowledge"); + verify(player, never()).getItemOnCursor(); + verify(player, never()).getEnderChest(); + verify(inventory, never()).getArmorContents(); + } + + @Test + void eligibleOwnerWithAuthenticStoredCompassDiscoversEachRecipeOnlyOnce() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack authentic = mock(ItemStack.class); + PocketBaseManager pockets = mock(PocketBaseManager.class); + PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class); + PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class); + Set known = new HashSet<>(); + when(registry.isAvailable()).thenReturn(true); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getStorageContents()).thenReturn(new ItemStack[] {authentic}); + when(inventory.getItemInOffHand()).thenReturn(null); + when(sharing.isAuthentic(authentic)).thenReturn(true); + when(pockets.state(playerId)).thenReturn(new PocketBaseState(playerId, 1, Optional.empty())); + when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0))); + when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0))); + PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery( + registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE, + Logger.getAnonymousLogger()); + + discovery.reconcile(player); + discovery.reconcile(player); + + assertEquals(Set.of(KEYSTONE, CREATION, RECHARGE), known); + verify(player).discoverRecipe(KEYSTONE); + verify(player).discoverRecipe(CREATION); + verify(player).discoverRecipe(RECHARGE); + verify(player, never()).undiscoverRecipe(any()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseRecipeRegistryTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseRecipeRegistryTest.java new file mode 100644 index 0000000..01d66bc --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/PocketBaseRecipeRegistryTest.java @@ -0,0 +1,267 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.ShapedRecipe; +import org.junit.jupiter.api.Test; + +final class PocketBaseRecipeRegistryTest { + @Test + void allThreeRegisterAsOneOwnedFailClosedGroupWithoutTouchingConflicts() { + NamespacedKey keystone = NamespacedKey.minecraft("keystone"); + NamespacedKey creation = NamespacedKey.minecraft("sharing"); + NamespacedKey recharge = NamespacedKey.minecraft("sharing_recharge"); + Recipe first = recipe(keystone); + Recipe conflict = recipe(creation); + Recipe unattempted = recipe(recharge); + Server server = mock(Server.class); + when(server.addRecipe(first)).thenReturn(true); + when(server.addRecipe(conflict)).thenReturn(false); + when(server.getRecipe(keystone)).thenReturn(first); + when(server.removeRecipe(keystone)).thenReturn(true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(first, conflict, unattempted), Logger.getAnonymousLogger()); + + assertFalse(registry.register()); + assertFalse(registry.isAvailable()); + verify(server).removeRecipe(keystone); + verify(server, never()).removeRecipe(creation); + verify(server, never()).removeRecipe(recharge); + verify(server, never()).addRecipe(unattempted); + + registry.close(); + verify(server).removeRecipe(keystone); + + Server successfulServer = mock(Server.class); + when(successfulServer.addRecipe(any())).thenReturn(true); + when(successfulServer.getRecipe(keystone)).thenReturn(first); + when(successfulServer.getRecipe(creation)).thenReturn(conflict); + when(successfulServer.getRecipe(recharge)).thenReturn(unattempted); + when(successfulServer.removeRecipe(any())).thenReturn(true); + PocketBaseRecipeRegistry successful = new PocketBaseRecipeRegistry( + successfulServer, List.of(first, conflict, unattempted), Logger.getAnonymousLogger()); + assertTrue(successful.register()); + assertTrue(successful.isAvailable()); + successful.close(); + verify(successfulServer).removeRecipe(keystone); + verify(successfulServer).removeRecipe(creation); + verify(successfulServer).removeRecipe(recharge); + } + + @Test + void registrationDefinitionsReceiveUniqueLifecycleProvenanceGroups() { + ItemStack result = mock(ItemStack.class); + RecipeChoice choice = mock(RecipeChoice.class); + ShapedRecipe first = shapedRecipe( + NamespacedKey.minecraft("first"), result, choice); + ShapedRecipe second = shapedRecipe( + NamespacedKey.minecraft("second"), result, choice); + ShapedRecipe third = shapedRecipe( + NamespacedKey.minecraft("third"), result, choice); + + new PocketBaseRecipeRegistry( + mock(Server.class), List.of(first, second, third), Logger.getAnonymousLogger()); + + var groups = org.mockito.ArgumentCaptor.forClass(String.class); + verify(first).setGroup(groups.capture()); + verify(second).setGroup(groups.capture()); + verify(third).setGroup(groups.capture()); + assertEquals(3, groups.getAllValues().stream().distinct().count()); + assertTrue(groups.getAllValues().stream() + .allMatch(group -> group.startsWith("spigotbase-owned-"))); + } + + @Test + void cleanupPreservesALaterForeignReplacementUnderAnOwnedKey() { + NamespacedKey replacedKey = NamespacedKey.minecraft("replaced"); + NamespacedKey retainedKey = NamespacedKey.minecraft("retained"); + NamespacedKey thirdKey = NamespacedKey.minecraft("third"); + Recipe replaced = recipe(replacedKey); + Recipe replacement = recipe(replacedKey); + Recipe retained = recipe(retainedKey); + Recipe third = recipe(thirdKey); + Server server = mock(Server.class); + when(server.addRecipe(any())).thenReturn(true); + when(server.getRecipe(replacedKey)).thenReturn(replacement); + when(server.getRecipe(retainedKey)).thenReturn(retained); + when(server.getRecipe(thirdKey)).thenReturn(third); + when(server.removeRecipe(any())).thenReturn(true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(replaced, retained, third), Logger.getAnonymousLogger()); + assertTrue(registry.register()); + + registry.close(); + + verify(server, never()).removeRecipe(replacedKey); + verify(server).removeRecipe(retainedKey); + verify(server).removeRecipe(thirdKey); + } + + @Test + void cleanupRemovesAnActiveStructuralCopyOfTheOwnedDefinition() { + NamespacedKey firstKey = NamespacedKey.minecraft("structural"); + ItemStack result = mock(ItemStack.class); + RecipeChoice choice = mock(RecipeChoice.class); + ShapedRecipe definition = shapedRecipe(firstKey, result, choice); + ShapedRecipe activeCopy = shapedRecipe(firstKey, result, choice); + Recipe second = recipe(NamespacedKey.minecraft("second")); + Recipe third = recipe(NamespacedKey.minecraft("third")); + Server server = mock(Server.class); + when(server.addRecipe(any())).thenReturn(true); + when(server.getRecipe(firstKey)).thenReturn(activeCopy); + when(server.getRecipe(((Keyed) second).getKey())).thenReturn(second); + when(server.getRecipe(((Keyed) third).getKey())).thenReturn(third); + when(server.removeRecipe(any())).thenReturn(true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(definition, second, third), Logger.getAnonymousLogger()); + assertTrue(registry.register()); + + registry.close(); + + verify(server).removeRecipe(firstKey); + } + + @Test + void runtimeSymbolRenamingStillMatchesTheOwnedIngredientGrid() { + NamespacedKey firstKey = NamespacedKey.minecraft("renamed-symbols"); + ItemStack result = mock(ItemStack.class); + RecipeChoice firstChoice = mock(RecipeChoice.class); + RecipeChoice secondChoice = mock(RecipeChoice.class); + ShapedRecipe definition = shapedRecipe(firstKey, result, + new String[] {"ABA", "BAB", "ABA"}, + Map.of('A', firstChoice, 'B', secondChoice)); + ShapedRecipe activeCopy = shapedRecipe(firstKey, result, + new String[] {"abc", "def", "ghi"}, + Map.of( + 'a', firstChoice, 'b', secondChoice, 'c', firstChoice, + 'd', secondChoice, 'e', firstChoice, 'f', secondChoice, + 'g', firstChoice, 'h', secondChoice, 'i', firstChoice)); + Recipe second = recipe(NamespacedKey.minecraft("second")); + Recipe third = recipe(NamespacedKey.minecraft("third")); + Server server = mock(Server.class); + when(server.addRecipe(any())).thenReturn(true); + when(server.getRecipe(firstKey)).thenReturn(activeCopy); + when(server.getRecipe(((Keyed) second).getKey())).thenReturn(second); + when(server.getRecipe(((Keyed) third).getKey())).thenReturn(third); + when(server.removeRecipe(any())).thenReturn(true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(definition, second, third), Logger.getAnonymousLogger()); + assertTrue(registry.register()); + + assertTrue(registry.isAvailable()); + assertTrue(registry.controls(activeCopy)); + registry.close(); + verify(server).removeRecipe(firstKey); + } + + @Test + void failedOwnedCleanupIsRetriedOnDisableWithoutTouchingTheConflict() { + NamespacedKey owned = NamespacedKey.minecraft("owned"); + NamespacedKey conflict = NamespacedKey.minecraft("conflict"); + Recipe first = recipe(owned); + Recipe second = recipe(conflict); + Recipe third = recipe(NamespacedKey.minecraft("third")); + Server server = mock(Server.class); + when(server.addRecipe(first)).thenReturn(true); + when(server.addRecipe(second)).thenReturn(false); + when(server.getRecipe(owned)).thenReturn(first); + when(server.removeRecipe(owned)) + .thenThrow(new IllegalStateException("temporary cleanup failure")) + .thenReturn(true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(first, second, third), Logger.getAnonymousLogger()); + + assertFalse(registry.register()); + assertTrue(registry.controls(first), + "A failed-removal residual must remain controlled while active"); + registry.close(); + assertFalse(registry.controls(first)); + + verify(server, times(2)).removeRecipe(owned); + verify(server, never()).removeRecipe(conflict); + } + + @Test + void falseRemovalResultRetainsOwnershipForDisableRetry() { + NamespacedKey owned = NamespacedKey.minecraft("false-removal"); + Recipe first = recipe(owned); + Recipe conflict = recipe(NamespacedKey.minecraft("conflict")); + Recipe third = recipe(NamespacedKey.minecraft("third")); + Server server = mock(Server.class); + when(server.addRecipe(first)).thenReturn(true); + when(server.addRecipe(conflict)).thenReturn(false); + when(server.getRecipe(owned)).thenReturn(first); + when(server.removeRecipe(owned)).thenReturn(false, true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(first, conflict, third), Logger.getAnonymousLogger()); + + assertFalse(registry.register()); + assertTrue(registry.controls(first)); + registry.close(); + assertFalse(registry.controls(first)); + verify(server, times(2)).removeRecipe(owned); + } + + @Test + void postInsertionRegistrationExceptionOwnsAndRollsBackTheInsertedRecipe() { + NamespacedKey firstKey = NamespacedKey.minecraft("first"); + NamespacedKey insertedKey = NamespacedKey.minecraft("inserted-before-exception"); + Recipe first = recipe(firstKey); + Recipe inserted = recipe(insertedKey); + Recipe third = recipe(NamespacedKey.minecraft("third")); + Server server = mock(Server.class); + when(server.addRecipe(first)).thenReturn(true); + when(server.addRecipe(inserted)) + .thenThrow(new IllegalStateException("reload failed after insertion")); + when(server.getRecipe(firstKey)).thenReturn(first); + when(server.getRecipe(insertedKey)).thenReturn(inserted); + when(server.removeRecipe(any())).thenReturn(true); + PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry( + server, List.of(first, inserted, third), Logger.getAnonymousLogger()); + + assertFalse(registry.register()); + + verify(server).removeRecipe(firstKey); + verify(server).removeRecipe(insertedKey); + verify(server, never()).addRecipe(third); + assertFalse(registry.controls(inserted)); + } + + private static ShapedRecipe shapedRecipe( + NamespacedKey key, ItemStack result, RecipeChoice choice) { + return shapedRecipe(key, result, new String[] {"A"}, Map.of('A', choice)); + } + + private static ShapedRecipe shapedRecipe( + NamespacedKey key, + ItemStack result, + String[] shape, + Map choices + ) { + ShapedRecipe recipe = mock(ShapedRecipe.class); + when(recipe.getKey()).thenReturn(key); + when(recipe.getResult()).thenReturn(result); + when(recipe.getShape()).thenReturn(shape); + when(recipe.getGroup()).thenReturn(""); + when(recipe.getCategory()).thenReturn( + org.bukkit.inventory.recipe.CraftingBookCategory.MISC); + when(recipe.getChoiceMap()).thenReturn(choices); + return recipe; + } + + private static Recipe recipe(NamespacedKey key) { + Recipe recipe = mock(Recipe.class, withSettings().extraInterfaces(Keyed.class)); + when(((Keyed) recipe).getKey()).thenReturn(key); + return recipe; + } +} diff --git a/src/test/java/games/dmg/spigotbase/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotbase/PurpurDistributionTest.java index 26ec3b7..e12c578 100644 --- a/src/test/java/games/dmg/spigotbase/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotbase/PurpurDistributionTest.java @@ -23,6 +23,8 @@ class PurpurDistributionTest { assertEquals("SpigotBase", metadata.get("name"), "Do not orphan the existing plugin data folder or default namespace"); assertEquals("games.dmg.spigotbase.SpigotBasePlugin", metadata.get("main")); assertTrue(((Map) metadata.get("permissions")).containsKey("spigotbase.admin")); + assertNotNull(jar.getJarEntry("games/dmg/spigotbase/PocketBaseRecipeRegistry.class")); + assertNotNull(jar.getJarEntry("games/dmg/spigotbase/PocketBaseRecipeDiscovery.class")); try (var bytecode = new DataInputStream(jar.getInputStream( jar.getJarEntry("games/dmg/spigotbase/SpigotBasePlugin.class")))) { assertEquals(0xCAFEBABE, bytecode.readInt());