Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe6a14451 | ||
|
|
76f94f6c15 |
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,10 @@ final class PocketBaseManager {
|
||||
return worlds.ownerForWorld(worldId);
|
||||
}
|
||||
|
||||
Optional<Location> availablePocketArrival(UUID ownerId) {
|
||||
return state(ownerId).level() > 0 ? worlds.availableArrival(ownerId) : Optional.empty();
|
||||
}
|
||||
|
||||
Location pocketArrival(UUID ownerId) {
|
||||
return worlds.arrival(ownerId);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
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;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.inventory.ClickType;
|
||||
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. */
|
||||
final class PocketBaseSharingCompass implements Listener {
|
||||
private final Server server;
|
||||
private final PocketBaseManager pockets;
|
||||
private final NamespacedKey key;
|
||||
private final NamespacedKey rechargeKey;
|
||||
private final NamespacedKey ownerKey;
|
||||
private final NamespacedKey chargesKey;
|
||||
private final NamespacedKey identityKey;
|
||||
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;
|
||||
this.pockets = pockets;
|
||||
this.key = key;
|
||||
this.rechargeKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_recharge");
|
||||
this.ownerKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner");
|
||||
this.chargesKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_charges");
|
||||
this.identityKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_id");
|
||||
this.ownerNameKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner_name");
|
||||
this.roll = roll;
|
||||
}
|
||||
|
||||
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);
|
||||
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', 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)
|
||||
|| !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)) {
|
||||
event.getInventory().setResult(rechargeAllowed(matrix, player, event.getInventory().getLocation())
|
||||
? withCharges(matrix[4], state(matrix[4]).charges() + 8) : null);
|
||||
return;
|
||||
}
|
||||
if (matrix.length != 9 || !material(matrix[4], Material.END_CRYSTAL)) return;
|
||||
event.getInventory().setResult(creationMatrix(matrix) && pockets.state(player.getUniqueId()).level() > 0
|
||||
? create(player.getUniqueId(), player.getName()) : null);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(CraftItemEvent event) {
|
||||
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);
|
||||
if (!creation && !recharge) {
|
||||
event.getInventory().setResult(null);
|
||||
return;
|
||||
}
|
||||
if (empty(event.getInventory().getResult()) || !(event.getWhoClicked() instanceof Player player)
|
||||
|| event.getRawSlot() != 0 || event.getView().getType() != InventoryType.WORKBENCH) return;
|
||||
if (creation ? pockets.state(player.getUniqueId()).level() < 1
|
||||
: !rechargeAllowed(matrix, player, event.getInventory().getLocation())) return;
|
||||
boolean shift = event.isShiftClick();
|
||||
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT
|
||||
&& event.getClick() != ClickType.SHIFT_LEFT && event.getClick() != ClickType.SHIFT_RIGHT) return;
|
||||
int slot = shift ? player.getInventory().firstEmpty() : -1;
|
||||
if (shift ? slot < 0 : !empty(event.getCursor())) return;
|
||||
ItemStack output;
|
||||
if (creation) output = create(player.getUniqueId(), player.getName());
|
||||
else {
|
||||
long proposed = state(matrix[4]).charges() + 8;
|
||||
output = roll.getAsInt() < successChance(proposed) ? withCharges(matrix[4], proposed) : null;
|
||||
}
|
||||
for (int i = 0; i < matrix.length; i++) {
|
||||
matrix[i].setAmount(matrix[i].getAmount() - 1);
|
||||
if (matrix[i].getAmount() == 0) matrix[i] = null;
|
||||
}
|
||||
event.getInventory().setResult(null);
|
||||
event.getInventory().setMatrix(matrix);
|
||||
if (output == null) {
|
||||
Location table = event.getInventory().getLocation();
|
||||
// Native damage/block destruction and protection events, not a cosmetic effect.
|
||||
// Consume first: an explosion may synchronously kill the crafter or close the menu.
|
||||
Location center = new Location(table.getWorld(), table.getBlockX() + 0.5,
|
||||
table.getBlockY() + 0.5, table.getBlockZ() + 0.5);
|
||||
// The shorter overload excludes its source from damage; the crafter must remain eligible.
|
||||
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)
|
||||
public void onUse(PlayerInteractEvent event) {
|
||||
if ((event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK)
|
||||
|| event.useItemInHand() == Event.Result.DENY || event.getHand() == null) return;
|
||||
Player player = event.getPlayer();
|
||||
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;
|
||||
var destination = pockets.availablePocketArrival(state.owner());
|
||||
if (destination.isEmpty() || !travelling.add(player.getUniqueId())) return;
|
||||
float fallDistance = player.getFallDistance();
|
||||
boolean teleported = false;
|
||||
try {
|
||||
player.setFallDistance(0F);
|
||||
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);
|
||||
travelling.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
private void spendCarriedCharge(Player player, UUID identity) {
|
||||
// Teleport listeners may move/remove the item, change the held slot, or trigger death.
|
||||
// Never reinstall the pre-teleport snapshot over a replacement item or resurrect a drop.
|
||||
ItemStack[] items = player.getInventory().getContents();
|
||||
for (int slot = 0; slot < items.length; slot++) {
|
||||
State current = state(items[slot]);
|
||||
if (current != null && current.identity().equals(identity)) {
|
||||
if (current.charges() > 0) player.getInventory().setItem(slot, withCharges(items[slot], current.charges() - 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
ItemStack cursor = player.getItemOnCursor();
|
||||
State current = state(cursor);
|
||||
if (current != null && current.identity().equals(identity) && current.charges() > 0) {
|
||||
player.setItemOnCursor(withCharges(cursor, current.charges() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
Player player = event.getEntity();
|
||||
ItemStack[] carried = player.getInventory().getContents();
|
||||
for (int i = 0; i < carried.length; i++) {
|
||||
if (state(carried[i]) != null) player.getInventory().setItem(i, withCharges(carried[i], 0));
|
||||
}
|
||||
if (state(player.getItemOnCursor()) != null) player.setItemOnCursor(withCharges(player.getItemOnCursor(), 0));
|
||||
discharge(event.getDrops());
|
||||
discharge(event.getItemsToKeep());
|
||||
}
|
||||
|
||||
private void discharge(List<ItemStack> items) {
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
if (state(items.get(i)) != null) items.set(i, withCharges(items.get(i), 0));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean ourRecipe(Recipe recipe) {
|
||||
return recipe instanceof Keyed keyed
|
||||
&& (key.equals(keyed.getKey()) || rechargeKey.equals(keyed.getKey()));
|
||||
}
|
||||
|
||||
private static int successChance(long proposed) {
|
||||
return proposed >= 73 ? 10 : 100 - 10 * (int) ((proposed - 1) / 8);
|
||||
}
|
||||
|
||||
private boolean rechargeCandidate(ItemStack[] matrix) {
|
||||
if (matrix.length != 9 || !material(matrix[4], Material.COMPASS)) return false;
|
||||
// Do not hijack ordinary map/recovery-compass recipes. Marked items still fail
|
||||
// closed when their metadata or surrounding ingredients are malformed.
|
||||
if (matrix[4].getItemMeta().getPersistentDataContainer().has(key)) return true;
|
||||
for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean rechargeAllowed(ItemStack[] matrix, Player player, Location table) {
|
||||
State state = matrix.length == 9 ? state(matrix[4]) : null;
|
||||
if (state == null || state.charges() > Long.MAX_VALUE - 8 || table == null || table.getWorld() == null
|
||||
|| !table.getWorld().equals(player.getWorld())
|
||||
|| table.getBlock().getType() != Material.CRAFTING_TABLE
|
||||
|| !pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()
|
||||
|| pockets.state(state.owner()).level() < 1) return false;
|
||||
for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false;
|
||||
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();
|
||||
if (meta == null) return null;
|
||||
var data = meta.getPersistentDataContainer();
|
||||
try {
|
||||
Byte marker = data.get(key, PersistentDataType.BYTE);
|
||||
String owner = data.get(ownerKey, PersistentDataType.STRING);
|
||||
Long charges = data.get(chargesKey, PersistentDataType.LONG);
|
||||
String identity = data.get(identityKey, PersistentDataType.STRING);
|
||||
if (marker == null || marker != 1 || owner == null || identity == null
|
||||
|| charges == null || charges < 0) return null;
|
||||
UUID ownerId = UUID.fromString(owner);
|
||||
UUID itemId = UUID.fromString(identity);
|
||||
if (!ownerId.toString().equals(owner) || !itemId.toString().equals(identity)) return null;
|
||||
return new State(ownerId, charges, itemId);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ItemStack withCharges(ItemStack item, long charges) {
|
||||
ItemStack updated = normalizeAuthentic(item);
|
||||
if (updated == item) {
|
||||
updated = item.clone();
|
||||
}
|
||||
var meta = updated.getItemMeta();
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(chargesKey, PersistentDataType.LONG, charges);
|
||||
String owner = data.get(ownerKey, PersistentDataType.STRING);
|
||||
String name = data.getOrDefault(ownerNameKey, PersistentDataType.STRING, owner);
|
||||
String risk = charges > Long.MAX_VALUE - 8 ? "Next recharge: unavailable (numeric overflow)"
|
||||
: "Next recharge: " + successChance(charges + 8) + "% success / "
|
||||
+ (100 - successChance(charges + 8)) + "% explosion";
|
||||
meta.lore(List.of(
|
||||
Component.text("Destination: " + name + " (" + owner + ")"),
|
||||
Component.text("Charges: " + charges),
|
||||
Component.text("Right-click for entry only; no return trip."),
|
||||
Component.text("Recharge: center of table + 8 ender pearls, inside destination."),
|
||||
Component.text(risk)
|
||||
));
|
||||
updated.setItemMeta(meta);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private record State(UUID owner, long charges, UUID identity) {}
|
||||
|
||||
private static boolean empty(ItemStack item) {
|
||||
return item == null || item.getType().isAir() || item.getAmount() == 0;
|
||||
}
|
||||
|
||||
private static boolean material(ItemStack item, Material material) {
|
||||
return item != null && item.getAmount() > 0 && item.getType() == material;
|
||||
}
|
||||
|
||||
private static boolean creationMatrix(ItemStack[] matrix) {
|
||||
if (matrix.length != 9) return false;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (!material(matrix[i], i == 4 ? Material.END_CRYSTAL
|
||||
: i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private ItemStack create(UUID owner, String name) {
|
||||
ItemStack item = new ItemStack(Material.COMPASS);
|
||||
var meta = item.getItemMeta();
|
||||
var data = meta.getPersistentDataContainer();
|
||||
data.set(key, PersistentDataType.BYTE, (byte) 1);
|
||||
data.set(ownerKey, PersistentDataType.STRING, owner.toString());
|
||||
data.set(ownerNameKey, PersistentDataType.STRING, name);
|
||||
data.set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString());
|
||||
data.set(chargesKey, PersistentDataType.LONG, 0L);
|
||||
meta.itemName(Component.text("Pocket Base Sharing Compass"));
|
||||
meta.setMaxStackSize(1);
|
||||
item.setItemMeta(meta);
|
||||
return withCharges(item, 0);
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,13 @@ final class PocketBaseWorldService {
|
||||
world.save();
|
||||
}
|
||||
|
||||
Optional<Location> availableArrival(UUID ownerId) {
|
||||
// A guest item must not recreate a missing world or grant an unlock.
|
||||
World world = server.getWorld(worldName(ownerId));
|
||||
return world == null ? Optional.empty()
|
||||
: Optional.of(new Location(world, 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f));
|
||||
}
|
||||
|
||||
Location arrival(UUID ownerId) {
|
||||
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -18,6 +20,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
private PocketBaseKeystoneService keystoneService;
|
||||
private PocketBaseSharingCompass sharingCompass;
|
||||
private PocketBaseRecipeRegistry recipeRegistry;
|
||||
private PocketBaseRecipeDiscovery recipeDiscovery;
|
||||
private BaseIdentificationController identificationController;
|
||||
|
||||
@Override
|
||||
@@ -53,7 +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));
|
||||
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);
|
||||
@@ -112,6 +138,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||
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
|
||||
@@ -121,7 +150,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
stateManager,
|
||||
settingsProvider,
|
||||
flightController,
|
||||
pocketBaseManager
|
||||
pocketBaseManager,
|
||||
recipeDiscovery
|
||||
);
|
||||
command("basesettings").setExecutor(settingsCommand);
|
||||
command("basesettings").setTabCompleter(settingsCommand);
|
||||
@@ -168,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));
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,13 @@ import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
|
||||
final class NativeRuntime {
|
||||
private static boolean initialized;
|
||||
|
||||
private NativeRuntime() {}
|
||||
|
||||
static void bootstrap() throws Exception {
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
// Paper registries are process-global and reject loading a second time.
|
||||
if (initialized) return;
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
@@ -54,5 +58,6 @@ final class NativeRuntime {
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,13 +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 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");
|
||||
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());
|
||||
@@ -51,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());
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SharingCompassDestinationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void destinationUsesPersistedUnlockAndLoadedWorldWithoutCreatingMissingWorlds() throws Exception {
|
||||
UUID owner = UUID.randomUUID();
|
||||
var repository = new YamlPocketBaseRepository(directory.resolve("pockets.yml"));
|
||||
var states = new PocketBaseStateManager(repository);
|
||||
states.updateAndSave(owner, state -> state.withLevel(1));
|
||||
var server = mock(Server.class);
|
||||
var settings = new PluginSettingsProvider(PluginSettings.from(Map.of()));
|
||||
var worlds = new PocketBaseWorldService(server, settings);
|
||||
var manager = new PocketBaseManager(new PocketBaseStateManager(repository), worlds, settings);
|
||||
assertTrue(manager.availablePocketArrival(owner).isEmpty());
|
||||
verify(server, never()).createWorld(any());
|
||||
var world = mock(World.class);
|
||||
when(server.getWorld("spigot_base_pocket_" + owner.toString().replace("-", ""))).thenReturn(world);
|
||||
assertEquals(new Location(world, .5, 65, 5.5, 180F, 0F), manager.availablePocketArrival(owner).orElseThrow());
|
||||
assertTrue(manager.availablePocketArrival(UUID.randomUUID()).isEmpty());
|
||||
verify(server, never()).createWorld(any());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user