Compare commits

..
4 Commits
Author SHA1 Message Date
dmg 4a8daf1a4f docs: point story tracking to Kaneo
CI / build (push) Successful in 2m53s
Release / release (push) Successful in 3m27s
2026-10-28 20:36:02 -04:00
dmg ffecd38e99 fix(base): support Purpur 26.3
Release / release (push) Successful in 2m0s
CI / build (push) Successful in 2m1s
2026-10-16 23:53:40 +00:00
dmg 5949eff7fd fix(base): support Purpur 26.3
CI / build (pull_request) Successful in 2m11s
CI / build (push) Successful in 2m8s
2026-10-16 19:50:38 -04:00
dmg 0fe6a14451 feat(base): discover pocket base travel recipes
Release / release (push) Successful in 4m59s
CI / build (push) Successful in 1m14s
2026-09-20 17:00:41 -04:00
19 changed files with 1660 additions and 45 deletions

No files matched your search

+5 -3
View File
@@ -1,9 +1,11 @@
# purpur-base agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Canonical engineering guidance and technical/process documents are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-base/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-base/` stories. Also follow the parent workspace `AGENTS.md` when present.
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-base] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-base/index.md`, `engineering.md` in that project section, and relevant Kaneo tickets with subject prefix `[purpur-base]`. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-base/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+3 -3
View File
@@ -1,6 +1,6 @@
# Purpur Base
A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
A Purpur 26.3 plugin providing progression-gated player bases and quality-of-life unlocks.
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
@@ -10,7 +10,7 @@ The repository and new artifacts are named `purpur-base`. Runtime identity remai
## Requirements
- Purpur 26.2 build 2618
- Purpur 26.3 build 2651
- Java 25 or newer
## Build
@@ -19,7 +19,7 @@ The repository and new artifacts are named `purpur-base`. Runtime identity remai
./gradlew clean check jar
```
The plugin JAR is written to `build/libs/`. `check` includes native label packet tests against the SHA-256-verified Purpur 2618 runtime. The build downloads/patches that runtime under `build/label-runtime/`, loads its registries, and exercises codecs without starting a listening Minecraft server.
The plugin JAR is written to `build/libs/`. `check` includes native label packet tests against the SHA-256-verified Purpur 2651 runtime. The build downloads/patches that runtime under `build/label-runtime/`, loads its registries, and exercises codecs without starting a listening Minecraft server.
## Player commands
+7 -7
View File
@@ -26,9 +26,9 @@ tasks.withType<JavaCompile>().configureEach {
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -46,18 +46,18 @@ tasks.test {
// Verify client-only label packets against the declared runtime, without starting a server.
val labelRuntime = layout.buildDirectory.dir("label-runtime")
val downloadLabelRuntime = tasks.register("downloadLabelRuntime") {
val launcher = labelRuntime.map { it.file("purpur-26.2-2618.jar") }
val launcher = labelRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download").toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
@@ -65,7 +65,7 @@ val downloadLabelRuntime = tasks.register("downloadLabelRuntime") {
val prepareLabelRuntime = tasks.register<JavaExec>("prepareLabelRuntime") {
dependsOn(downloadLabelRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(labelRuntime.map { it.file("purpur-26.2-2618.jar") })
classpath = files(labelRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(labelRuntime)
@@ -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();
}
@@ -45,7 +45,7 @@ class NativeBaseLabelPacketsTest {
try {
net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.encode(buffer, remove);
var decoded = net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.decode(buffer);
assertEquals(java.util.List.of(42), decoded.getEntityIds());
assertEquals(java.util.List.of(42), decoded.entityIds());
} finally { buffer.release(); }
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
@@ -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));
}
}
@@ -43,12 +43,12 @@ final class NativeRuntime {
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
@@ -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;
}
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
@@ -23,22 +24,45 @@ 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());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort(), "Plugin bytecode must target Java 25");
}
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
List<String> forbiddenPrefixes = List.of(
"com/comphenix/protocol/",
"com/destroystokyo/paper/",
"io/papermc/",
"net/minecraft/",
"org/bukkit/",
"org/purpurmc/",
"org/spigotmc/",
"mockito-extensions/"
);
assertFalse(jar.stream().anyMatch(entry -> forbiddenPrefixes.stream().anyMatch(entry.getName()::startsWith)
|| entry.getName().endsWith("Test.class")
|| entry.getName().equals("games/dmg/spigotbase/NativeRuntime.class")));
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(build.contains("26.2"));
assertFalse(build.contains("2618"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
String readme = Files.readString(Path.of("README.md"));
assertTrue(readme.contains("Purpur 26.3 build 2651"));
assertFalse(readme.contains("Purpur 26.2"));
assertFalse(readme.contains("2618"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));