feat(base): discover pocket base travel recipes
This commit is contained in:
1 parent
76f94f6c15
commit
0fe6a14451
14 files changed
+1614
-23
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<Recipe> 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<Recipe> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NamespacedKey> 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<? extends Player> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Recipe> recipes;
|
||||
private final Logger logger;
|
||||
private final Map<NamespacedKey, Recipe> owned = new LinkedHashMap<>();
|
||||
private boolean attempted;
|
||||
private boolean available;
|
||||
|
||||
PocketBaseRecipeRegistry(Server server, List<? extends Recipe> 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<NamespacedKey> 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<Character, RecipeChoice> expectedChoices = expected.getChoiceMap();
|
||||
Map<Character, RecipeChoice> 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<String> expectedMaterials = materialKeys(expected);
|
||||
Set<String> 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<ItemStack> expectedChoices = expectedItems.getChoices();
|
||||
List<ItemStack> actualChoices = actualItems.getChoices();
|
||||
return expectedChoices.equals(actualChoices);
|
||||
}
|
||||
return Objects.equals(expected, actual);
|
||||
}
|
||||
|
||||
private static Set<String> materialKeys(RecipeChoice choice) {
|
||||
Set<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> travelling = new HashSet<>();
|
||||
private PocketBaseRecipeDiscovery recipeDiscovery;
|
||||
private BooleanSupplier recipesAvailable = () -> true;
|
||||
private Predicate<Recipe> 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<Recipe> controlled) {
|
||||
this.recipeControlled = controlled;
|
||||
}
|
||||
|
||||
List<ShapedRecipe> 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);
|
||||
|
||||
@@ -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<Recipe>();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<Recipe<?>> vanilla = recipeKey("minecraft:wooden_pickaxe");
|
||||
ResourceKey<Recipe<?>> 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<Recipe<?>> recipeKey(String value) {
|
||||
return ResourceKey.create(Registries.RECIPE, Identifier.parse(value));
|
||||
}
|
||||
}
|
||||
@@ -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> 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<Recipe>();
|
||||
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<NamespacedKey> 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<NamespacedKey> 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<net.minecraft.world.inventory.Slot>();
|
||||
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<org.junit.jupiter.params.provider.Arguments> rechargeBoundaries() {
|
||||
var cases = new ArrayList<org.junit.jupiter.params.provider.Arguments>();
|
||||
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();
|
||||
|
||||
@@ -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<List<?>>();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
|
||||
verify(inventory, times(2)).setStorageContents(contents.capture());
|
||||
ItemStack[] restored = contents.getAllValues().get(1);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<NamespacedKey> 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<NamespacedKey> 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<NamespacedKey> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Character, RecipeChoice> 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user