Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffecd38e99 | ||
|
|
5949eff7fd | ||
|
|
0fe6a14451 | ||
|
|
76f94f6c15 | ||
|
|
682bd9850b |
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -1,14 +1,16 @@
|
||||
# Spigot Base
|
||||
# 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.
|
||||
|
||||
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
|
||||
|
||||
- Purpur 26.2 build 2618
|
||||
- Purpur 26.3 build 2651
|
||||
- Java 25 or newer
|
||||
|
||||
## Build
|
||||
@@ -17,7 +19,7 @@ The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev
|
||||
./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
|
||||
|
||||
|
||||
+10
-7
@@ -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")
|
||||
@@ -38,23 +38,26 @@ 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.
|
||||
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)
|
||||
}
|
||||
@@ -62,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)
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -39,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());
|
||||
@@ -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,73 @@
|
||||
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.List;
|
||||
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");
|
||||
}
|
||||
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.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"));
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user