Compare commits

...
3 Commits
Author SHA1 Message Date
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
dmg 76f94f6c15 feat(base): add charged Pocket Base sharing compasses
Release / release (push) Successful in 4m43s
CI / build (push) Successful in 3m35s
2026-09-17 23:31:16 -04:00
dmg 682bd9850b feat(base): adopt Purpur distribution naming
CI / build (push) Successful in 2m46s
Release / release (push) Successful in 7m39s
BREAKING CHANGE: New release assets use purpur-base-<version>.jar. Runtime SpigotBase identity, data folders, permissions and persistent item keys remain unchanged. Replace the old JAR rather than loading both distributions.
2026-09-12 12:32:03 -04:00
24 changed files with 2851 additions and 22 deletions

No files matched your search

+1 -1
View File
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact
uses: actions/upload-artifact@v3
with:
name: spigot-base-${{ github.sha }}
name: purpur-base-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+4 -4
View File
@@ -86,8 +86,8 @@ jobs:
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3
with:
name: spigot-base-${{ steps.release.outputs.version }}
path: build/libs/spigot-base-${{ steps.release.outputs.version }}.jar
name: purpur-base-${{ steps.release.outputs.version }}
path: build/libs/purpur-base-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: |
api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-base-${VERSION}.jar"
jar="build/libs/purpur-base-${VERSION}.jar"
export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-base-${VERSION}.jar"
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-base-${VERSION}.jar"
+3 -3
View File
@@ -1,9 +1,9 @@
# spigot-base agent entrypoint
# 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).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-base/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-base/` stories. Also follow the parent workspace `AGENTS.md` when present.
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.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-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.
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.
+4 -2
View File
@@ -1,10 +1,12 @@
# Spigot Base
# Purpur Base
A Purpur 26.2 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.
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-base/index.md).
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-base/index.md).
The repository and new artifacts are named `purpur-base`. Runtime identity remains `SpigotBase`: existing `plugins/SpigotBase/` data, commands, `spigotbase.*` permissions, item keys and Java entrypoints remain compatible. Replace the old JAR when installing a new release; never load both distributions together. Historical `spigot-base-*` release assets are retained.
## Requirements
+3
View File
@@ -38,6 +38,9 @@ dependencies {
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
// Verify client-only label packets against the declared runtime, without starting a server.
+1 -1
View File
@@ -2,4 +2,4 @@ plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-base"
rootProject.name = "purpur-base"
@@ -29,6 +29,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController;
private final PocketBaseManager pocketBases;
private final PocketBaseRecipeDiscovery recipeDiscovery;
private final DialogProvider dialogProvider;
BaseSettingsCommand(
@@ -44,12 +45,26 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases
) {
this(
stateManager, settings, flightController, pocketBases,
(PocketBaseRecipeDiscovery) null
);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases,
PocketBaseRecipeDiscovery recipeDiscovery
) {
this(
stateManager,
settings,
flightController,
pocketBases,
recipeDiscovery,
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
.create(owner, pocket)
);
@@ -61,12 +76,24 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
BaseFlightController flightController,
PocketBaseManager pocketBases,
DialogProvider dialogProvider
) {
this(stateManager, settings, flightController, pocketBases, null, dialogProvider);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases,
PocketBaseRecipeDiscovery recipeDiscovery,
DialogProvider dialogProvider
) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController;
this.pocketBases = pocketBases;
this.recipeDiscovery = recipeDiscovery;
this.dialogProvider = dialogProvider;
}
@@ -202,6 +229,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ "The Pocket Base upgrade failed; your payment was restored.");
return true;
}
if (recipeDiscovery != null) {
recipeDiscovery.discoverCreation(player);
}
player.sendTitle(
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
ChatColor.YELLOW + "Build a portal inside your base",
@@ -5,6 +5,8 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
@@ -26,8 +28,10 @@ import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.persistence.PersistentDataType;
final class PocketBaseKeystoneService implements Listener {
@@ -46,6 +50,8 @@ final class PocketBaseKeystoneService implements Listener {
private final KeystoneReturnStore returns;
private final NamespacedKey markerKey;
private final Logger logger;
private BooleanSupplier recipesAvailable = () -> true;
private Predicate<Recipe> recipeControlled = this::ourRecipe;
PocketBaseKeystoneService(
Server server,
@@ -61,18 +67,30 @@ final class PocketBaseKeystoneService implements Listener {
this.logger = logger;
}
void setRecipesAvailable(BooleanSupplier available) {
this.recipesAvailable = available;
}
void setRecipeControl(Predicate<Recipe> controlled) {
this.recipeControlled = controlled;
}
@EventHandler(priority = EventPriority.HIGH)
public void onPrepareCraft(PrepareItemCraftEvent event) {
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|| !shaped.getKey().equals(markerKey)
|| !(event.getView().getPlayer() instanceof Player player)) {
if (!(event.getView().getPlayer() instanceof Player player)
|| !recipeControlled.test(event.getRecipe())) {
return;
}
if (pocketBases.state(player.getUniqueId()).level() < 1) {
if (!recipesAvailable.getAsBoolean()
|| pocketBases.state(player.getUniqueId()).level() < 1) {
event.getInventory().setResult(null);
}
}
private boolean ourRecipe(Recipe recipe) {
return recipe instanceof ShapedRecipe shaped && shaped.getKey().equals(markerKey);
}
@EventHandler(priority = EventPriority.HIGH)
public void onUse(PlayerInteractEvent event) {
Action action = event.getAction();
@@ -200,8 +218,12 @@ final class PocketBaseKeystoneService implements Listener {
return marker != null && marker == (byte) 1;
}
ShapedRecipe recipe() {
return recipe(markerKey, createKeystone(markerKey));
}
void registerRecipe() {
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
server.addRecipe(recipe());
}
static ItemStack createKeystone(NamespacedKey markerKey) {
@@ -244,6 +266,7 @@ final class PocketBaseKeystoneService implements Listener {
recipe.setIngredient('O', specification.obsidian());
recipe.setIngredient('N', specification.netherite());
recipe.setIngredient('E', specification.enderPearl());
recipe.setCategory(CraftingBookCategory.EQUIPMENT);
return recipe;
}
@@ -187,6 +187,10 @@ final class PocketBaseManager {
return worlds.ownerForWorld(worldId);
}
Optional<Location> availablePocketArrival(UUID ownerId) {
return state(ownerId).level() > 0 ? worlds.availableArrival(ownerId) : Optional.empty();
}
Location pocketArrival(UUID ownerId) {
return worlds.arrival(ownerId);
}
@@ -0,0 +1,117 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.ItemStack;
/** Awards recipe-book knowledge; crafting handlers remain the authority. */
final class PocketBaseRecipeDiscovery implements Listener {
private final PocketBaseRecipeRegistry registry;
private final PocketBaseManager pockets;
private final PocketBaseSharingCompass sharing;
private final List<NamespacedKey> creationKeys;
private final NamespacedKey rechargeKey;
private final Logger logger;
PocketBaseRecipeDiscovery(
PocketBaseRecipeRegistry registry,
PocketBaseManager pockets,
PocketBaseSharingCompass sharing,
NamespacedKey keystoneKey,
NamespacedKey sharingCreationKey,
NamespacedKey rechargeKey,
Logger logger
) {
this.registry = Objects.requireNonNull(registry, "registry");
this.pockets = Objects.requireNonNull(pockets, "pockets");
this.sharing = Objects.requireNonNull(sharing, "sharing");
this.creationKeys = List.of(keystoneKey, sharingCreationKey);
this.rechargeKey = Objects.requireNonNull(rechargeKey, "recharge key");
this.logger = Objects.requireNonNull(logger, "logger");
}
void reconcileOnline(Iterable<? extends Player> players) {
for (Player player : players) {
reconcile(player);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
reconcile(event.getPlayer());
}
void reconcile(Player player) {
try {
sharing.normalizeCarried(player);
if (!registry.isAvailable()) {
return;
}
if (pockets.state(player.getUniqueId()).level() > 0) {
for (NamespacedKey key : creationKeys) {
awardSafely(player, key);
}
}
if (carriesAuthenticCompass(player)) {
awardSafely(player, rechargeKey);
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not reconcile Pocket Base recipe discovery for " + player.getUniqueId(),
exception);
}
}
void discoverCreation(Player player) {
if (!registry.isAvailable()) {
return;
}
for (NamespacedKey key : creationKeys) {
awardSafely(player, key);
}
}
void discoverRecharge(Player player) {
if (registry.isAvailable()) {
awardSafely(player, rechargeKey);
}
}
private boolean carriesAuthenticCompass(Player player) {
for (ItemStack item : player.getInventory().getStorageContents()) {
if (sharing.isAuthentic(item)) {
return true;
}
}
return sharing.isAuthentic(player.getInventory().getItemInOffHand());
}
private void awardSafely(Player player, NamespacedKey key) {
try {
award(player, key);
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not discover Pocket Base recipe " + key + " for "
+ player.getUniqueId(), exception);
}
}
private void award(Player player, NamespacedKey key) {
if (player.hasDiscoveredRecipe(key)) {
return;
}
player.discoverRecipe(key);
if (!player.hasDiscoveredRecipe(key)) {
logger.warning("Pocket Base recipe discovery postcondition was not met for "
+ player.getUniqueId() + " and " + key + "; a later reconciliation will retry");
}
}
}
@@ -0,0 +1,240 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
/** Owns only recipes successfully added during this plugin lifecycle. */
final class PocketBaseRecipeRegistry implements AutoCloseable {
private static final int REQUIRED_RECIPE_COUNT = 3;
private final Server server;
private final List<Recipe> recipes;
private final Logger logger;
private final Map<NamespacedKey, Recipe> owned = new LinkedHashMap<>();
private boolean attempted;
private boolean available;
PocketBaseRecipeRegistry(Server server, List<? extends Recipe> recipes, Logger logger) {
this.server = Objects.requireNonNull(server, "server");
this.recipes = List.copyOf(recipes);
this.logger = Objects.requireNonNull(logger, "logger");
if (this.recipes.size() != REQUIRED_RECIPE_COUNT) {
throw new IllegalArgumentException("Pocket Base travel requires exactly three recipes");
}
Set<NamespacedKey> keys = new HashSet<>();
String lifecycle = UUID.randomUUID().toString();
for (Recipe recipe : this.recipes) {
if (!(recipe instanceof Keyed keyed) || !keys.add(keyed.getKey())) {
throw new IllegalArgumentException("Pocket Base travel recipes require three unique keys");
}
if (recipe instanceof ShapedRecipe shaped) {
shaped.setGroup("spigotbase-owned-" + lifecycle + "-" + keyed.getKey());
}
}
}
boolean register() {
if (attempted) {
return available;
}
attempted = true;
try {
for (Recipe recipe : recipes) {
NamespacedKey key = ((Keyed) recipe).getKey();
boolean added;
try {
added = server.addRecipe(recipe);
} catch (RuntimeException exception) {
try {
if (sameDefinition(recipe, server.getRecipe(key))) {
owned.put(key, recipe);
}
} catch (RuntimeException inspectionFailure) {
exception.addSuppressed(inspectionFailure);
}
throw exception;
}
if (!added) {
logger.warning("Pocket Base travel recipe registration is incomplete at "
+ key + "; recipe discovery is disabled for this lifecycle");
rollback();
return false;
}
owned.put(key, recipe);
}
available = true;
return true;
} catch (RuntimeException exception) {
logger.log(Level.SEVERE,
"Pocket Base travel recipe registration failed; recipe discovery is disabled for this lifecycle",
exception);
rollback();
return false;
}
}
boolean isAvailable() {
if (!available) {
return false;
}
for (var entry : owned.entrySet()) {
try {
if (!sameDefinition(entry.getValue(), server.getRecipe(entry.getKey()))) {
available = false;
return false;
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not verify active Pocket Base travel recipe " + entry.getKey(),
exception);
return false;
}
}
return owned.size() == REQUIRED_RECIPE_COUNT;
}
boolean controls(Recipe candidate) {
if (!(candidate instanceof Keyed keyed)) {
return false;
}
Recipe definition = owned.get(keyed.getKey());
if (!sameDefinition(definition, candidate)) {
return false;
}
try {
boolean controls = sameDefinition(definition, server.getRecipe(keyed.getKey()));
if (!controls) {
available = false;
}
return controls;
} catch (RuntimeException exception) {
available = false;
logger.log(Level.WARNING,
"Could not verify active Pocket Base travel recipe " + keyed.getKey()
+ "; keeping its handler fail-closed",
exception);
return true;
}
}
private void rollback() {
available = false;
for (var entry : Map.copyOf(owned).entrySet()) {
NamespacedKey key = entry.getKey();
Recipe active;
try {
active = server.getRecipe(key);
} catch (RuntimeException exception) {
logger.log(Level.SEVERE,
"Could not verify owned Pocket Base travel recipe " + key, exception);
continue;
}
if (!sameDefinition(entry.getValue(), active)) {
owned.remove(key);
continue;
}
try {
if (server.removeRecipe(key)) {
owned.remove(key);
}
} catch (RuntimeException exception) {
logger.log(Level.SEVERE, "Could not remove owned Pocket Base travel recipe " + key,
exception);
}
}
}
private static boolean sameDefinition(Recipe expected, Recipe actual) {
if (expected == actual) {
return true;
}
if (!(expected instanceof ShapedRecipe expectedShaped)
|| !(actual instanceof ShapedRecipe actualShaped)) {
return false;
}
if (!expectedShaped.getKey().equals(actualShaped.getKey())
|| !expectedShaped.getResult().equals(actualShaped.getResult())
|| !sameIngredientGrid(expectedShaped, actualShaped)
|| !expectedShaped.getGroup().equals(actualShaped.getGroup())
|| expectedShaped.getCategory() != actualShaped.getCategory()) {
return false;
}
return true;
}
private static boolean sameIngredientGrid(
ShapedRecipe expected, ShapedRecipe actual) {
String[] expectedShape = expected.getShape();
String[] actualShape = actual.getShape();
if (expectedShape.length != actualShape.length) {
return false;
}
Map<Character, RecipeChoice> expectedChoices = expected.getChoiceMap();
Map<Character, RecipeChoice> actualChoices = actual.getChoiceMap();
for (int row = 0; row < expectedShape.length; row++) {
if (expectedShape[row].length() != actualShape[row].length()) {
return false;
}
for (int column = 0; column < expectedShape[row].length(); column++) {
RecipeChoice expectedChoice = expectedChoices.get(
expectedShape[row].charAt(column));
RecipeChoice actualChoice = actualChoices.get(
actualShape[row].charAt(column));
if (!sameChoice(expectedChoice, actualChoice)) {
return false;
}
}
}
return true;
}
private static boolean sameChoice(RecipeChoice expected, RecipeChoice actual) {
Set<String> expectedMaterials = materialKeys(expected);
Set<String> actualMaterials = materialKeys(actual);
if (expectedMaterials != null || actualMaterials != null) {
return Objects.equals(expectedMaterials, actualMaterials);
}
if (expected instanceof RecipeChoice.ExactChoice expectedItems
&& actual instanceof RecipeChoice.ExactChoice actualItems) {
List<ItemStack> expectedChoices = expectedItems.getChoices();
List<ItemStack> actualChoices = actualItems.getChoices();
return expectedChoices.equals(actualChoices);
}
return Objects.equals(expected, actual);
}
private static Set<String> materialKeys(RecipeChoice choice) {
Set<String> keys = new HashSet<>();
if (choice instanceof RecipeChoice.MaterialChoice materials) {
for (var material : materials.getChoices()) {
keys.add(material.getKey().asString());
}
return Set.copyOf(keys);
}
if (choice instanceof RecipeChoice.ItemTypeChoice itemTypes) {
for (var itemType : itemTypes.itemTypes().values()) {
keys.add(itemType.key().asString());
}
return Set.copyOf(keys);
}
return null;
}
@Override
public void close() {
rollback();
}
}
@@ -0,0 +1,411 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.function.IntSupplier;
import java.util.function.Predicate;
import net.kyori.adventure.text.Component;
import org.bukkit.Keyed;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerRecipeBookClickEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.persistence.PersistentDataType;
/** Destination-bound guest access, separate from holder-bound Keystones. */
final class PocketBaseSharingCompass implements Listener {
private final Server server;
private final PocketBaseManager pockets;
private final NamespacedKey key;
private final NamespacedKey rechargeKey;
private final NamespacedKey ownerKey;
private final NamespacedKey chargesKey;
private final NamespacedKey identityKey;
private final NamespacedKey ownerNameKey;
private final IntSupplier roll;
private final Set<UUID> travelling = new HashSet<>();
private PocketBaseRecipeDiscovery recipeDiscovery;
private BooleanSupplier recipesAvailable = () -> true;
private Predicate<Recipe> recipeControlled = this::ourRecipe;
PocketBaseSharingCompass(Server server, PocketBaseManager pockets, NamespacedKey key, IntSupplier roll) {
this.server = server;
this.pockets = pockets;
this.key = key;
this.rechargeKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_recharge");
this.ownerKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner");
this.chargesKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_charges");
this.identityKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_id");
this.ownerNameKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner_name");
this.roll = roll;
}
void setRecipeDiscovery(PocketBaseRecipeDiscovery discovery) {
this.recipeDiscovery = discovery;
}
void setRecipesAvailable(BooleanSupplier available) {
this.recipesAvailable = available;
}
void setRecipeControl(Predicate<Recipe> controlled) {
this.recipeControlled = controlled;
}
List<ShapedRecipe> recipes() {
var creation = new ShapedRecipe(key, representative("Creation"));
creation.shape("DED", "ECE", "DED");
creation.setIngredient('D', Material.DIAMOND_BLOCK);
creation.setIngredient('E', Material.ECHO_SHARD);
creation.setIngredient('C', Material.END_CRYSTAL);
creation.setCategory(CraftingBookCategory.EQUIPMENT);
var recharge = new ShapedRecipe(rechargeKey, representative("Recharge"));
recharge.shape("PPP", "PCP", "PPP");
recharge.setIngredient('P', Material.ENDER_PEARL);
recharge.setIngredient('C', new RecipeChoice.MaterialChoice(Material.COMPASS));
recharge.setCategory(CraftingBookCategory.EQUIPMENT);
return List.of(creation, recharge);
}
void registerRecipes() {
recipes().forEach(server::addRecipe);
}
private static ItemStack representative(String purpose) {
ItemStack item = new ItemStack(Material.COMPASS);
var meta = item.getItemMeta();
meta.displayName(Component.text(
"Pocket Base Sharing Compass — " + purpose + " Representative"));
meta.lore(List.of(
Component.text("Non-authoritative recipe-book display only."),
Component.text("Only an authenticated bound Sharing Compass is usable.")
));
item.setItemMeta(meta);
return item;
}
@EventHandler(priority = EventPriority.LOWEST)
public void onRecipeBookClick(PlayerRecipeBookClickEvent event) {
if (event.getOriginalRecipe() instanceof Keyed keyed
&& rechargeKey.equals(keyed.getKey())
&& recipeControlled.test(event.getOriginalRecipe())) {
normalizeStorage(event.getPlayer());
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPrepare(PrepareItemCraftEvent event) {
if (!(event.getView().getPlayer() instanceof Player player)
|| !recipeControlled.test(event.getRecipe())
|| empty(event.getInventory().getResult())) return;
// Only definitions still owned by this lifecycle are intercepted.
if (!recipesAvailable.getAsBoolean()) {
event.getInventory().setResult(null);
return;
}
// Only our candidate matrices/results are replaced; unrelated recipes remain untouched.
ItemStack[] matrix = event.getInventory().getMatrix();
if (rechargeCandidate(matrix)) {
event.getInventory().setResult(rechargeAllowed(matrix, player, event.getInventory().getLocation())
? withCharges(matrix[4], state(matrix[4]).charges() + 8) : null);
return;
}
if (matrix.length != 9 || !material(matrix[4], Material.END_CRYSTAL)) return;
event.getInventory().setResult(creationMatrix(matrix) && pockets.state(player.getUniqueId()).level() > 0
? create(player.getUniqueId(), player.getName()) : null);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(CraftItemEvent event) {
if (!recipeControlled.test(event.getRecipe())) return;
event.setCancelled(true);
if (!recipesAvailable.getAsBoolean()) {
event.getInventory().setResult(null);
return;
}
ItemStack[] matrix = event.getInventory().getMatrix();
boolean creation = creationMatrix(matrix);
boolean recharge = rechargeCandidate(matrix);
if (!creation && !recharge) {
event.getInventory().setResult(null);
return;
}
if (empty(event.getInventory().getResult()) || !(event.getWhoClicked() instanceof Player player)
|| event.getRawSlot() != 0 || event.getView().getType() != InventoryType.WORKBENCH) return;
if (creation ? pockets.state(player.getUniqueId()).level() < 1
: !rechargeAllowed(matrix, player, event.getInventory().getLocation())) return;
boolean shift = event.isShiftClick();
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT
&& event.getClick() != ClickType.SHIFT_LEFT && event.getClick() != ClickType.SHIFT_RIGHT) return;
int slot = shift ? player.getInventory().firstEmpty() : -1;
if (shift ? slot < 0 : !empty(event.getCursor())) return;
ItemStack output;
if (creation) output = create(player.getUniqueId(), player.getName());
else {
long proposed = state(matrix[4]).charges() + 8;
output = roll.getAsInt() < successChance(proposed) ? withCharges(matrix[4], proposed) : null;
}
for (int i = 0; i < matrix.length; i++) {
matrix[i].setAmount(matrix[i].getAmount() - 1);
if (matrix[i].getAmount() == 0) matrix[i] = null;
}
event.getInventory().setResult(null);
event.getInventory().setMatrix(matrix);
if (output == null) {
Location table = event.getInventory().getLocation();
// Native damage/block destruction and protection events, not a cosmetic effect.
// Consume first: an explosion may synchronously kill the crafter or close the menu.
Location center = new Location(table.getWorld(), table.getBlockX() + 0.5,
table.getBlockY() + 0.5, table.getBlockZ() + 0.5);
// The shorter overload excludes its source from damage; the crafter must remain eligible.
table.getWorld().createExplosion(player, center, 6.0F, false, true, false);
} else if (shift) player.getInventory().setItem(slot, output);
else event.getView().setCursor(output);
if (creation && output != null && recipeDiscovery != null) {
recipeDiscovery.discoverRecharge(player);
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onUse(PlayerInteractEvent event) {
if ((event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK)
|| event.useItemInHand() == Event.Result.DENY || event.getHand() == null) return;
Player player = event.getPlayer();
ItemStack item = player.getInventory().getItem(event.getHand());
State state = state(item);
if (state == null || !item.equals(event.getItem())) return;
ItemStack normalized = normalizeAuthentic(item);
if (normalized != item) {
player.getInventory().setItem(event.getHand(), normalized);
}
event.setCancelled(true);
if (state.charges() == 0 || pockets.state(state.owner()).level() < 1
|| pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()) return;
var destination = pockets.availablePocketArrival(state.owner());
if (destination.isEmpty() || !travelling.add(player.getUniqueId())) return;
float fallDistance = player.getFallDistance();
boolean teleported = false;
try {
player.setFallDistance(0F);
teleported = player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN);
if (teleported && !player.isDead() && player.getWorld().equals(destination.orElseThrow().getWorld())) {
spendCarriedCharge(player, state.identity());
if (recipeDiscovery != null) {
recipeDiscovery.discoverRecharge(player);
}
}
} finally {
if (!teleported) player.setFallDistance(fallDistance);
travelling.remove(player.getUniqueId());
}
}
private void spendCarriedCharge(Player player, UUID identity) {
// Teleport listeners may move/remove the item, change the held slot, or trigger death.
// Never reinstall the pre-teleport snapshot over a replacement item or resurrect a drop.
ItemStack[] items = player.getInventory().getContents();
for (int slot = 0; slot < items.length; slot++) {
State current = state(items[slot]);
if (current != null && current.identity().equals(identity)) {
if (current.charges() > 0) player.getInventory().setItem(slot, withCharges(items[slot], current.charges() - 1));
return;
}
}
ItemStack cursor = player.getItemOnCursor();
State current = state(cursor);
if (current != null && current.identity().equals(identity) && current.charges() > 0) {
player.setItemOnCursor(withCharges(cursor, current.charges() - 1));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDeath(PlayerDeathEvent event) {
Player player = event.getEntity();
ItemStack[] carried = player.getInventory().getContents();
for (int i = 0; i < carried.length; i++) {
if (state(carried[i]) != null) player.getInventory().setItem(i, withCharges(carried[i], 0));
}
if (state(player.getItemOnCursor()) != null) player.setItemOnCursor(withCharges(player.getItemOnCursor(), 0));
discharge(event.getDrops());
discharge(event.getItemsToKeep());
}
private void discharge(List<ItemStack> items) {
for (int i = 0; i < items.size(); i++) {
if (state(items.get(i)) != null) items.set(i, withCharges(items.get(i), 0));
}
}
private boolean ourRecipe(Recipe recipe) {
return recipe instanceof Keyed keyed
&& (key.equals(keyed.getKey()) || rechargeKey.equals(keyed.getKey()));
}
private static int successChance(long proposed) {
return proposed >= 73 ? 10 : 100 - 10 * (int) ((proposed - 1) / 8);
}
private boolean rechargeCandidate(ItemStack[] matrix) {
if (matrix.length != 9 || !material(matrix[4], Material.COMPASS)) return false;
// Do not hijack ordinary map/recovery-compass recipes. Marked items still fail
// closed when their metadata or surrounding ingredients are malformed.
if (matrix[4].getItemMeta().getPersistentDataContainer().has(key)) return true;
for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false;
return true;
}
private boolean rechargeAllowed(ItemStack[] matrix, Player player, Location table) {
State state = matrix.length == 9 ? state(matrix[4]) : null;
if (state == null || state.charges() > Long.MAX_VALUE - 8 || table == null || table.getWorld() == null
|| !table.getWorld().equals(player.getWorld())
|| table.getBlock().getType() != Material.CRAFTING_TABLE
|| !pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()
|| pockets.state(state.owner()).level() < 1) return false;
for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false;
return true;
}
boolean isAuthentic(ItemStack item) {
return state(item) != null;
}
ItemStack normalizeAuthentic(ItemStack item) {
if (state(item) == null) {
return item;
}
var meta = item.getItemMeta();
if (!meta.hasCustomName()) {
return item;
}
Component visibleTitle = meta.customName();
ItemStack normalized = item.clone();
var normalizedMeta = normalized.getItemMeta();
normalizedMeta.itemName(visibleTitle);
normalizedMeta.customName(null);
normalized.setItemMeta(normalizedMeta);
return normalized;
}
void normalizeCarried(Player player) {
normalizeStorage(player);
var inventory = player.getInventory();
ItemStack offhand = inventory.getItemInOffHand();
ItemStack normalizedOffhand = normalizeAuthentic(offhand);
if (normalizedOffhand != offhand) {
inventory.setItem(EquipmentSlot.OFF_HAND, normalizedOffhand);
}
}
private void normalizeStorage(Player player) {
var inventory = player.getInventory();
ItemStack[] storage = inventory.getStorageContents();
for (int slot = 0; slot < storage.length; slot++) {
ItemStack normalized = normalizeAuthentic(storage[slot]);
if (normalized != storage[slot]) {
inventory.setItem(slot, normalized);
}
}
}
private State state(ItemStack item) {
if (!material(item, Material.COMPASS) || item.getAmount() != 1) return null;
var meta = item.getItemMeta();
if (meta == null) return null;
var data = meta.getPersistentDataContainer();
try {
Byte marker = data.get(key, PersistentDataType.BYTE);
String owner = data.get(ownerKey, PersistentDataType.STRING);
Long charges = data.get(chargesKey, PersistentDataType.LONG);
String identity = data.get(identityKey, PersistentDataType.STRING);
if (marker == null || marker != 1 || owner == null || identity == null
|| charges == null || charges < 0) return null;
UUID ownerId = UUID.fromString(owner);
UUID itemId = UUID.fromString(identity);
if (!ownerId.toString().equals(owner) || !itemId.toString().equals(identity)) return null;
return new State(ownerId, charges, itemId);
} catch (IllegalArgumentException exception) {
return null;
}
}
private ItemStack withCharges(ItemStack item, long charges) {
ItemStack updated = normalizeAuthentic(item);
if (updated == item) {
updated = item.clone();
}
var meta = updated.getItemMeta();
var data = meta.getPersistentDataContainer();
data.set(chargesKey, PersistentDataType.LONG, charges);
String owner = data.get(ownerKey, PersistentDataType.STRING);
String name = data.getOrDefault(ownerNameKey, PersistentDataType.STRING, owner);
String risk = charges > Long.MAX_VALUE - 8 ? "Next recharge: unavailable (numeric overflow)"
: "Next recharge: " + successChance(charges + 8) + "% success / "
+ (100 - successChance(charges + 8)) + "% explosion";
meta.lore(List.of(
Component.text("Destination: " + name + " (" + owner + ")"),
Component.text("Charges: " + charges),
Component.text("Right-click for entry only; no return trip."),
Component.text("Recharge: center of table + 8 ender pearls, inside destination."),
Component.text(risk)
));
updated.setItemMeta(meta);
return updated;
}
private record State(UUID owner, long charges, UUID identity) {}
private static boolean empty(ItemStack item) {
return item == null || item.getType().isAir() || item.getAmount() == 0;
}
private static boolean material(ItemStack item, Material material) {
return item != null && item.getAmount() > 0 && item.getType() == material;
}
private static boolean creationMatrix(ItemStack[] matrix) {
if (matrix.length != 9) return false;
for (int i = 0; i < 9; i++) {
if (!material(matrix[i], i == 4 ? Material.END_CRYSTAL
: i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD)) return false;
}
return true;
}
private ItemStack create(UUID owner, String name) {
ItemStack item = new ItemStack(Material.COMPASS);
var meta = item.getItemMeta();
var data = meta.getPersistentDataContainer();
data.set(key, PersistentDataType.BYTE, (byte) 1);
data.set(ownerKey, PersistentDataType.STRING, owner.toString());
data.set(ownerNameKey, PersistentDataType.STRING, name);
data.set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString());
data.set(chargesKey, PersistentDataType.LONG, 0L);
meta.itemName(Component.text("Pocket Base Sharing Compass"));
meta.setMaxStackSize(1);
item.setItemMeta(meta);
return withCharges(item, 0);
}
}
@@ -109,6 +109,13 @@ final class PocketBaseWorldService {
world.save();
}
Optional<Location> availableArrival(UUID ownerId) {
// A guest item must not recreate a missing world or grant an unlock.
World world = server.getWorld(worldName(ownerId));
return world == null ? Optional.empty()
: Optional.of(new Location(world, 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f));
}
Location arrival(UUID ownerId) {
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
}
@@ -2,11 +2,13 @@ package games.dmg.spigotbase;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.NamespacedKey;
import org.bukkit.command.PluginCommand;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotBasePlugin extends JavaPlugin {
@@ -18,6 +20,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController;
private PocketBaseKeystoneService keystoneService;
private PocketBaseSharingCompass sharingCompass;
private PocketBaseRecipeRegistry recipeRegistry;
private PocketBaseRecipeDiscovery recipeDiscovery;
private BaseIdentificationController identificationController;
@Override
@@ -53,7 +58,28 @@ public final class SpigotBasePlugin extends JavaPlugin {
keystoneKey,
getLogger()
);
keystoneService.registerRecipe();
sharingCompass = new PocketBaseSharingCompass(getServer(), pocketBaseManager,
new NamespacedKey(this, "sharing_compass"),
() -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100));
var recipes = new ArrayList<Recipe>();
recipes.add(keystoneService.recipe());
recipes.addAll(sharingCompass.recipes());
recipeRegistry = new PocketBaseRecipeRegistry(getServer(), recipes, getLogger());
recipeRegistry.register();
keystoneService.setRecipesAvailable(recipeRegistry::isAvailable);
keystoneService.setRecipeControl(recipeRegistry::controls);
sharingCompass.setRecipesAvailable(recipeRegistry::isAvailable);
sharingCompass.setRecipeControl(recipeRegistry::controls);
recipeDiscovery = new PocketBaseRecipeDiscovery(
recipeRegistry,
pocketBaseManager,
sharingCompass,
keystoneKey,
new NamespacedKey(this, "sharing_compass"),
new NamespacedKey(this, "sharing_compass_recharge"),
getLogger()
);
sharingCompass.setRecipeDiscovery(recipeDiscovery);
} catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this);
@@ -112,6 +138,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(teleportManager, this);
getServer().getPluginManager().registerEvents(pocketBaseController, this);
getServer().getPluginManager().registerEvents(keystoneService, this);
getServer().getPluginManager().registerEvents(sharingCompass, this);
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
recipeDiscovery.reconcileOnline(getServer().getOnlinePlayers());
command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController
@@ -121,7 +150,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
stateManager,
settingsProvider,
flightController,
pocketBaseManager
pocketBaseManager,
recipeDiscovery
);
command("basesettings").setExecutor(settingsCommand);
command("basesettings").setTabCompleter(settingsCommand);
@@ -168,6 +198,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
if (identificationController != null) {
identificationController.close();
}
if (recipeRegistry != null) {
recipeRegistry.close();
}
if (progressListener != null) {
progressListener.removeAllBossBars();
}
@@ -0,0 +1,57 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import io.netty.buffer.Unpooled;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ServerboundPlaceRecipePacket;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.stats.ServerRecipeBook;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraft.world.item.crafting.display.RecipeDisplayId;
import org.junit.jupiter.api.Test;
/** Native protocol/book checks pinned to the supported Purpur runtime. */
final class NativeRecipeBookTest {
@Test
void placeRecipePacketCodecPreservesAutofillSelectionAndMode() {
var expected = new ServerboundPlaceRecipePacket(
17, new RecipeDisplayId(42), true);
var buffer = new FriendlyByteBuf(Unpooled.buffer());
ServerboundPlaceRecipePacket.STREAM_CODEC.encode(buffer, expected);
var decoded = ServerboundPlaceRecipePacket.STREAM_CODEC.decode(buffer);
assertEquals(expected, decoded);
assertEquals(17, decoded.containerId());
assertEquals(42, decoded.recipe().index());
assertTrue(decoded.useMaxItems());
assertFalse(buffer.isReadable(), "The pinned codec must consume exactly one request");
buffer.release();
}
@Test
void serverRecipeBookKnowledgeIsIdempotentAndCopiesAcrossLifecycle() {
ResourceKey<Recipe<?>> vanilla = recipeKey("minecraft:wooden_pickaxe");
ResourceKey<Recipe<?>> keystone = recipeKey("spigotbase:pocket_base_keystone");
ServerRecipeBook book = new ServerRecipeBook((recipe, consumer) -> {});
book.add(vanilla);
book.add(keystone);
book.add(keystone);
assertEquals(2, book.known.size());
assertTrue(book.contains(vanilla));
assertTrue(book.contains(keystone));
ServerRecipeBook reenabled = new ServerRecipeBook((recipe, consumer) -> {});
reenabled.copyOverData(book);
assertTrue(reenabled.contains(vanilla));
assertTrue(reenabled.contains(keystone));
}
private static ResourceKey<Recipe<?>> recipeKey(String value) {
return ResourceKey.create(Registries.RECIPE, Identifier.parse(value));
}
}
@@ -27,9 +27,13 @@ import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private static boolean initialized;
private NativeRuntime() {}
static void bootstrap() throws Exception {
static synchronized void bootstrap() throws Exception {
// Paper registries are process-global and reject loading a second time.
if (initialized) return;
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
@@ -54,5 +58,6 @@ final class NativeRuntime {
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
initialized = true;
}
}
File diff suppressed because it is too large. Load diff
@@ -4,11 +4,13 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.plugin.PluginManager;
import org.bukkit.scheduler.BukkitScheduler;
import org.junit.jupiter.api.Test;
@@ -36,13 +38,37 @@ class BaseIdentificationWiringTest {
doReturn(List.of()).when(server).getOnlinePlayers();
doCallRealMethod().when(plugin).onEnable();
doCallRealMethod().when(plugin).onDisable();
var settingsArguments = new ArrayList<List<?>>();
try (var validator = mockStatic(PluginSettingsValidator.class);
var keystones = mockConstruction(PocketBaseKeystoneService.class);
var keystones = mockConstruction(PocketBaseKeystoneService.class,
(service, context) -> when(service.recipe()).thenReturn(mock(ShapedRecipe.class)));
var sharing = mockConstruction(PocketBaseSharingCompass.class,
(service, context) -> when(service.recipes()).thenReturn(
List.of(mock(ShapedRecipe.class), mock(ShapedRecipe.class))));
var registries = mockConstruction(PocketBaseRecipeRegistry.class,
(registry, context) -> when(registry.register()).thenReturn(true));
var discoveries = mockConstruction(PocketBaseRecipeDiscovery.class);
var settingsCommands = mockConstruction(BaseSettingsCommand.class,
(command, context) -> settingsArguments.add(context.arguments()));
var packets = mockConstruction(NativeBaseLabelPackets.class);
var labels = mockConstruction(BukkitBaseLabels.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(any())).thenAnswer(call -> call.getArgument(0));
plugin.onEnable();
assertEquals(1, keystones.constructed().size());
assertEquals(1, sharing.constructed().size(), "Enable must install guest compasses alongside Keystones");
assertEquals(1, registries.constructed().size());
assertEquals(1, discoveries.constructed().size());
assertEquals(1, settingsCommands.constructed().size());
verify(registries.constructed().getFirst()).register();
verify(keystones.constructed().getFirst()).setRecipesAvailable(any());
verify(keystones.constructed().getFirst()).setRecipeControl(any());
verify(sharing.constructed().getFirst()).setRecipesAvailable(any());
verify(sharing.constructed().getFirst()).setRecipeControl(any());
verify(sharing.constructed().getFirst()).setRecipeDiscovery(discoveries.constructed().getFirst());
verify(discoveries.constructed().getFirst()).reconcileOnline(server.getOnlinePlayers());
verify(manager).registerEvents(discoveries.constructed().getFirst(), plugin);
verify(manager).registerEvents(sharing.constructed().getFirst(), plugin);
assertSame(discoveries.constructed().getFirst(), settingsArguments.getFirst().get(4));
verify(manager, never()).disablePlugin(plugin);
var tasks = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong());
@@ -51,6 +77,7 @@ class BaseIdentificationWiringTest {
verify(manager).registerEvents((BaseIdentificationController) identification.getFirst(), plugin);
identification.getFirst().run();
plugin.onDisable();
verify(registries.constructed().getFirst()).close();
assertEquals(1, packets.constructed().size());
verify(labels.constructed().getFirst()).forgetAll();
}
@@ -256,17 +256,20 @@ final class BaseSettingsCommandTest {
when(pocketBases.upgrade(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty())
);
PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
pocketBases,
discovery
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
verify(pocketBases).upgrade(playerId);
verify(inventory).setStorageContents(any(ItemStack[].class));
verify(discovery).discoverCreation(player);
}
@Test
@@ -454,15 +457,18 @@ final class BaseSettingsCommandTest {
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
pocketBases,
discovery
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
verify(discovery, never()).discoverCreation(player);
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
verify(inventory, times(2)).setStorageContents(contents.capture());
ItemStack[] restored = contents.getAllValues().get(1);
@@ -99,6 +99,36 @@ final class PocketBaseKeystoneServiceTest {
verify(inventory).setResult(null);
}
@Test
void residualOwnedKeystoneRecipeFailsClosedWhenRegistrationGroupIsUnavailable() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class);
InventoryView view = mock(InventoryView.class);
CraftingInventory inventory = mock(CraftingInventory.class);
ShapedRecipe recipe = mock(ShapedRecipe.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, mock(KeystoneReturnStore.class),
key, Logger.getAnonymousLogger()
);
service.setRecipesAvailable(() -> false);
service.setRecipeControl(candidate -> true);
when(event.getView()).thenReturn(view);
when(view.getPlayer()).thenReturn(player);
when(event.getInventory()).thenReturn(inventory);
when(event.getRecipe()).thenReturn(recipe);
when(player.getUniqueId()).thenReturn(playerId);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
service.onPrepareCraft(event);
verify(inventory).setResult(null);
}
@Test
void lockedPlayerCannotUseATransferredKeystone() throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
@@ -0,0 +1,170 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
final class PocketBaseRecipeDiscoveryTest {
private static final NamespacedKey KEYSTONE = NamespacedKey.minecraft("keystone");
private static final NamespacedKey CREATION = NamespacedKey.minecraft("sharing");
private static final NamespacedKey RECHARGE = NamespacedKey.minecraft("sharing_recharge");
@Test
void onlineReconciliationIsolatesPlayersAndRetriesMissingPostconditions() {
Player broken = mock(Player.class);
Player retrying = mock(Player.class);
UUID brokenId = UUID.randomUUID();
UUID retryingId = UUID.randomUUID();
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
when(registry.isAvailable()).thenReturn(true);
when(broken.getUniqueId()).thenReturn(brokenId);
when(retrying.getUniqueId()).thenReturn(retryingId);
when(pockets.state(brokenId)).thenReturn(new PocketBaseState(brokenId, 1, Optional.empty()));
when(pockets.state(retryingId)).thenReturn(new PocketBaseState(retryingId, 1, Optional.empty()));
when(broken.hasDiscoveredRecipe(any())).thenThrow(new IllegalStateException("broken book"));
when(retrying.hasDiscoveredRecipe(any())).thenReturn(false);
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
discovery.reconcileOnline(List.of(broken, retrying));
discovery.reconcileOnline(List.of(retrying));
verify(retrying, times(2)).discoverRecipe(KEYSTONE);
verify(retrying, times(2)).discoverRecipe(CREATION);
}
@Test
void completeCharacterResetPreservesPocketAndVanillaRecipeKnowledge() {
UUID playerId = UUID.randomUUID();
NamespacedKey vanilla = NamespacedKey.minecraft("wooden_pickaxe");
Set<NamespacedKey> known = new HashSet<>(Set.of(vanilla, KEYSTONE, CREATION, RECHARGE));
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
CommandSender administrator = mock(CommandSender.class);
BaseStateManager states = mock(BaseStateManager.class);
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
PlayerState character = PlayerState.newPlayer(playerId, "Builder");
when(administrator.hasPermission("spigotbase.admin")).thenReturn(true);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[0]);
when(inventory.getItemInOffHand()).thenReturn(null);
when(states.player(playerId, "Builder")).thenReturn(character);
when(pockets.state(playerId)).thenReturn(new PocketBaseState(playerId, 1, Optional.empty()));
when(registry.isAvailable()).thenReturn(true);
when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0)));
BaseAdminCommand reset = new BaseAdminCommand(
mock(JavaPlugin.class), states, mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(java.util.Map.of())));
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(player);
reset.onCommand(administrator, null, "baseadmin",
new String[] {"reset", "Builder", "all", "confirm"});
}
discovery.reconcile(player);
assertEquals(Set.of(vanilla, KEYSTONE, CREATION, RECHARGE), known);
verify(player, never()).undiscoverRecipe(any());
verify(player, never()).undiscoverRecipes(any());
verify(player, never()).discoverRecipe(any());
verify(pockets).state(playerId);
}
@Test
void joinReconciliationUsesOnlyStorageHotbarAndOffhandForAuthenticPossession() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
Inventory enderChest = mock(Inventory.class);
ItemStack authentic = mock(ItemStack.class);
ItemStack nameOnly = mock(ItemStack.class);
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
Set<NamespacedKey> known = new HashSet<>();
when(registry.isAvailable()).thenReturn(true);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getInventory()).thenReturn(inventory);
when(player.getEnderChest()).thenReturn(enderChest);
when(player.getItemOnCursor()).thenReturn(authentic);
when(inventory.getArmorContents()).thenReturn(new ItemStack[] {authentic});
when(enderChest.getContents()).thenReturn(new ItemStack[] {authentic});
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {nameOnly});
when(inventory.getItemInOffHand()).thenReturn(null, authentic);
when(sharing.isAuthentic(authentic)).thenReturn(true);
when(sharing.isAuthentic(nameOnly)).thenReturn(false);
when(pockets.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0)));
when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0)));
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
assertTrue(known.isEmpty(), "Armor, cursor, Ender Chest and name-only items are excluded");
discovery.onJoin(new PlayerJoinEvent(player, "joined again"));
assertEquals(Set.of(RECHARGE), known, "An authentic offhand compass earns recharge knowledge");
verify(player, never()).getItemOnCursor();
verify(player, never()).getEnderChest();
verify(inventory, never()).getArmorContents();
}
@Test
void eligibleOwnerWithAuthenticStoredCompassDiscoversEachRecipeOnlyOnce() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack authentic = mock(ItemStack.class);
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
Set<NamespacedKey> known = new HashSet<>();
when(registry.isAvailable()).thenReturn(true);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {authentic});
when(inventory.getItemInOffHand()).thenReturn(null);
when(sharing.isAuthentic(authentic)).thenReturn(true);
when(pockets.state(playerId)).thenReturn(new PocketBaseState(playerId, 1, Optional.empty()));
when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0)));
when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0)));
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
discovery.reconcile(player);
discovery.reconcile(player);
assertEquals(Set.of(KEYSTONE, CREATION, RECHARGE), known);
verify(player).discoverRecipe(KEYSTONE);
verify(player).discoverRecipe(CREATION);
verify(player).discoverRecipe(RECHARGE);
verify(player, never()).undiscoverRecipe(any());
}
}
@@ -0,0 +1,267 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Map;
import java.util.logging.Logger;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.junit.jupiter.api.Test;
final class PocketBaseRecipeRegistryTest {
@Test
void allThreeRegisterAsOneOwnedFailClosedGroupWithoutTouchingConflicts() {
NamespacedKey keystone = NamespacedKey.minecraft("keystone");
NamespacedKey creation = NamespacedKey.minecraft("sharing");
NamespacedKey recharge = NamespacedKey.minecraft("sharing_recharge");
Recipe first = recipe(keystone);
Recipe conflict = recipe(creation);
Recipe unattempted = recipe(recharge);
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(conflict)).thenReturn(false);
when(server.getRecipe(keystone)).thenReturn(first);
when(server.removeRecipe(keystone)).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, conflict, unattempted), Logger.getAnonymousLogger());
assertFalse(registry.register());
assertFalse(registry.isAvailable());
verify(server).removeRecipe(keystone);
verify(server, never()).removeRecipe(creation);
verify(server, never()).removeRecipe(recharge);
verify(server, never()).addRecipe(unattempted);
registry.close();
verify(server).removeRecipe(keystone);
Server successfulServer = mock(Server.class);
when(successfulServer.addRecipe(any())).thenReturn(true);
when(successfulServer.getRecipe(keystone)).thenReturn(first);
when(successfulServer.getRecipe(creation)).thenReturn(conflict);
when(successfulServer.getRecipe(recharge)).thenReturn(unattempted);
when(successfulServer.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry successful = new PocketBaseRecipeRegistry(
successfulServer, List.of(first, conflict, unattempted), Logger.getAnonymousLogger());
assertTrue(successful.register());
assertTrue(successful.isAvailable());
successful.close();
verify(successfulServer).removeRecipe(keystone);
verify(successfulServer).removeRecipe(creation);
verify(successfulServer).removeRecipe(recharge);
}
@Test
void registrationDefinitionsReceiveUniqueLifecycleProvenanceGroups() {
ItemStack result = mock(ItemStack.class);
RecipeChoice choice = mock(RecipeChoice.class);
ShapedRecipe first = shapedRecipe(
NamespacedKey.minecraft("first"), result, choice);
ShapedRecipe second = shapedRecipe(
NamespacedKey.minecraft("second"), result, choice);
ShapedRecipe third = shapedRecipe(
NamespacedKey.minecraft("third"), result, choice);
new PocketBaseRecipeRegistry(
mock(Server.class), List.of(first, second, third), Logger.getAnonymousLogger());
var groups = org.mockito.ArgumentCaptor.forClass(String.class);
verify(first).setGroup(groups.capture());
verify(second).setGroup(groups.capture());
verify(third).setGroup(groups.capture());
assertEquals(3, groups.getAllValues().stream().distinct().count());
assertTrue(groups.getAllValues().stream()
.allMatch(group -> group.startsWith("spigotbase-owned-")));
}
@Test
void cleanupPreservesALaterForeignReplacementUnderAnOwnedKey() {
NamespacedKey replacedKey = NamespacedKey.minecraft("replaced");
NamespacedKey retainedKey = NamespacedKey.minecraft("retained");
NamespacedKey thirdKey = NamespacedKey.minecraft("third");
Recipe replaced = recipe(replacedKey);
Recipe replacement = recipe(replacedKey);
Recipe retained = recipe(retainedKey);
Recipe third = recipe(thirdKey);
Server server = mock(Server.class);
when(server.addRecipe(any())).thenReturn(true);
when(server.getRecipe(replacedKey)).thenReturn(replacement);
when(server.getRecipe(retainedKey)).thenReturn(retained);
when(server.getRecipe(thirdKey)).thenReturn(third);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(replaced, retained, third), Logger.getAnonymousLogger());
assertTrue(registry.register());
registry.close();
verify(server, never()).removeRecipe(replacedKey);
verify(server).removeRecipe(retainedKey);
verify(server).removeRecipe(thirdKey);
}
@Test
void cleanupRemovesAnActiveStructuralCopyOfTheOwnedDefinition() {
NamespacedKey firstKey = NamespacedKey.minecraft("structural");
ItemStack result = mock(ItemStack.class);
RecipeChoice choice = mock(RecipeChoice.class);
ShapedRecipe definition = shapedRecipe(firstKey, result, choice);
ShapedRecipe activeCopy = shapedRecipe(firstKey, result, choice);
Recipe second = recipe(NamespacedKey.minecraft("second"));
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(any())).thenReturn(true);
when(server.getRecipe(firstKey)).thenReturn(activeCopy);
when(server.getRecipe(((Keyed) second).getKey())).thenReturn(second);
when(server.getRecipe(((Keyed) third).getKey())).thenReturn(third);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(definition, second, third), Logger.getAnonymousLogger());
assertTrue(registry.register());
registry.close();
verify(server).removeRecipe(firstKey);
}
@Test
void runtimeSymbolRenamingStillMatchesTheOwnedIngredientGrid() {
NamespacedKey firstKey = NamespacedKey.minecraft("renamed-symbols");
ItemStack result = mock(ItemStack.class);
RecipeChoice firstChoice = mock(RecipeChoice.class);
RecipeChoice secondChoice = mock(RecipeChoice.class);
ShapedRecipe definition = shapedRecipe(firstKey, result,
new String[] {"ABA", "BAB", "ABA"},
Map.of('A', firstChoice, 'B', secondChoice));
ShapedRecipe activeCopy = shapedRecipe(firstKey, result,
new String[] {"abc", "def", "ghi"},
Map.of(
'a', firstChoice, 'b', secondChoice, 'c', firstChoice,
'd', secondChoice, 'e', firstChoice, 'f', secondChoice,
'g', firstChoice, 'h', secondChoice, 'i', firstChoice));
Recipe second = recipe(NamespacedKey.minecraft("second"));
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(any())).thenReturn(true);
when(server.getRecipe(firstKey)).thenReturn(activeCopy);
when(server.getRecipe(((Keyed) second).getKey())).thenReturn(second);
when(server.getRecipe(((Keyed) third).getKey())).thenReturn(third);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(definition, second, third), Logger.getAnonymousLogger());
assertTrue(registry.register());
assertTrue(registry.isAvailable());
assertTrue(registry.controls(activeCopy));
registry.close();
verify(server).removeRecipe(firstKey);
}
@Test
void failedOwnedCleanupIsRetriedOnDisableWithoutTouchingTheConflict() {
NamespacedKey owned = NamespacedKey.minecraft("owned");
NamespacedKey conflict = NamespacedKey.minecraft("conflict");
Recipe first = recipe(owned);
Recipe second = recipe(conflict);
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(second)).thenReturn(false);
when(server.getRecipe(owned)).thenReturn(first);
when(server.removeRecipe(owned))
.thenThrow(new IllegalStateException("temporary cleanup failure"))
.thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, second, third), Logger.getAnonymousLogger());
assertFalse(registry.register());
assertTrue(registry.controls(first),
"A failed-removal residual must remain controlled while active");
registry.close();
assertFalse(registry.controls(first));
verify(server, times(2)).removeRecipe(owned);
verify(server, never()).removeRecipe(conflict);
}
@Test
void falseRemovalResultRetainsOwnershipForDisableRetry() {
NamespacedKey owned = NamespacedKey.minecraft("false-removal");
Recipe first = recipe(owned);
Recipe conflict = recipe(NamespacedKey.minecraft("conflict"));
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(conflict)).thenReturn(false);
when(server.getRecipe(owned)).thenReturn(first);
when(server.removeRecipe(owned)).thenReturn(false, true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, conflict, third), Logger.getAnonymousLogger());
assertFalse(registry.register());
assertTrue(registry.controls(first));
registry.close();
assertFalse(registry.controls(first));
verify(server, times(2)).removeRecipe(owned);
}
@Test
void postInsertionRegistrationExceptionOwnsAndRollsBackTheInsertedRecipe() {
NamespacedKey firstKey = NamespacedKey.minecraft("first");
NamespacedKey insertedKey = NamespacedKey.minecraft("inserted-before-exception");
Recipe first = recipe(firstKey);
Recipe inserted = recipe(insertedKey);
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(inserted))
.thenThrow(new IllegalStateException("reload failed after insertion"));
when(server.getRecipe(firstKey)).thenReturn(first);
when(server.getRecipe(insertedKey)).thenReturn(inserted);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, inserted, third), Logger.getAnonymousLogger());
assertFalse(registry.register());
verify(server).removeRecipe(firstKey);
verify(server).removeRecipe(insertedKey);
verify(server, never()).addRecipe(third);
assertFalse(registry.controls(inserted));
}
private static ShapedRecipe shapedRecipe(
NamespacedKey key, ItemStack result, RecipeChoice choice) {
return shapedRecipe(key, result, new String[] {"A"}, Map.of('A', choice));
}
private static ShapedRecipe shapedRecipe(
NamespacedKey key,
ItemStack result,
String[] shape,
Map<Character, RecipeChoice> choices
) {
ShapedRecipe recipe = mock(ShapedRecipe.class);
when(recipe.getKey()).thenReturn(key);
when(recipe.getResult()).thenReturn(result);
when(recipe.getShape()).thenReturn(shape);
when(recipe.getGroup()).thenReturn("");
when(recipe.getCategory()).thenReturn(
org.bukkit.inventory.recipe.CraftingBookCategory.MISC);
when(recipe.getChoiceMap()).thenReturn(choices);
return recipe;
}
private static Recipe recipe(NamespacedKey key) {
Recipe recipe = mock(Recipe.class, withSettings().extraInterfaces(Keyed.class));
when(((Keyed) recipe).getKey()).thenReturn(key);
return recipe;
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/** Distribution branding is deliberately independent of persistent/runtime plugin identity. */
class PurpurDistributionTest {
@Test
void packagesJava25UnderNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-base-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
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")));
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-base-"), workflow + " must publish the renamed distribution");
assertFalse(content.contains("spigot-base-"), workflow + " must not look for the old JAR filename");
}
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SharingCompassDestinationTest {
@TempDir Path directory;
@Test void destinationUsesPersistedUnlockAndLoadedWorldWithoutCreatingMissingWorlds() throws Exception {
UUID owner = UUID.randomUUID();
var repository = new YamlPocketBaseRepository(directory.resolve("pockets.yml"));
var states = new PocketBaseStateManager(repository);
states.updateAndSave(owner, state -> state.withLevel(1));
var server = mock(Server.class);
var settings = new PluginSettingsProvider(PluginSettings.from(Map.of()));
var worlds = new PocketBaseWorldService(server, settings);
var manager = new PocketBaseManager(new PocketBaseStateManager(repository), worlds, settings);
assertTrue(manager.availablePocketArrival(owner).isEmpty());
verify(server, never()).createWorld(any());
var world = mock(World.class);
when(server.getWorld("spigot_base_pocket_" + owner.toString().replace("-", ""))).thenReturn(world);
assertEquals(new Location(world, .5, 65, 5.5, 180F, 0F), manager.availablePocketArrival(owner).orElseThrow());
assertTrue(manager.availablePocketArrival(UUID.randomUUID()).isEmpty());
verify(server, never()).createWorld(any());
}
}