Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,10 +1,12 @@
|
||||
# Spigot Base
|
||||
# Purpur Base
|
||||
|
||||
A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
|
||||
|
||||
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
|
||||
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-base/index.md).
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-base/index.md).
|
||||
|
||||
The repository and new artifacts are named `purpur-base`. Runtime identity remains `SpigotBase`: existing `plugins/SpigotBase/` data, commands, `spigotbase.*` permissions, item keys and Java entrypoints remain compatible. Replace the old JAR when installing a new release; never load both distributions together. Historical `spigot-base-*` release assets are retained.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ dependencies {
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
dependsOn(tasks.jar)
|
||||
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
|
||||
systemProperty("distribution.version", project.version.toString())
|
||||
}
|
||||
|
||||
// Verify client-only label packets against the declared runtime, without starting a server.
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@ plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-base"
|
||||
rootProject.name = "purpur-base"
|
||||
@@ -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,298 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.IntSupplier;
|
||||
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.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
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<>();
|
||||
|
||||
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 registerRecipes() {
|
||||
var creation = new ShapedRecipe(key, new ItemStack(Material.COMPASS));
|
||||
creation.shape("DED", "ECE", "DED");
|
||||
creation.setIngredient('D', Material.DIAMOND_BLOCK);
|
||||
creation.setIngredient('E', Material.ECHO_SHARD);
|
||||
creation.setIngredient('C', Material.END_CRYSTAL);
|
||||
server.addRecipe(creation);
|
||||
var recharge = new ShapedRecipe(rechargeKey, new ItemStack(Material.COMPASS));
|
||||
recharge.shape("PPP", "PCP", "PPP");
|
||||
recharge.setIngredient('P', Material.ENDER_PEARL);
|
||||
recharge.setIngredient('C', Material.COMPASS);
|
||||
server.addRecipe(recharge);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onPrepare(PrepareItemCraftEvent event) {
|
||||
if (!(event.getView().getPlayer() instanceof Player player)
|
||||
|| !ourRecipe(event.getRecipe()) || empty(event.getInventory().getResult())) 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 (!ourRecipe(event.getRecipe())) return;
|
||||
event.setCancelled(true);
|
||||
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);
|
||||
}
|
||||
|
||||
@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;
|
||||
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());
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
var 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.displayName(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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
private PocketBaseKeystoneService keystoneService;
|
||||
private PocketBaseSharingCompass sharingCompass;
|
||||
private BaseIdentificationController identificationController;
|
||||
|
||||
@Override
|
||||
@@ -54,6 +55,10 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
getLogger()
|
||||
);
|
||||
keystoneService.registerRecipe();
|
||||
sharingCompass = new PocketBaseSharingCompass(getServer(), pocketBaseManager,
|
||||
new NamespacedKey(this, "sharing_compass"),
|
||||
() -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100));
|
||||
sharingCompass.registerRecipes();
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
@@ -112,6 +117,7 @@ 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);
|
||||
|
||||
command("setbase").setExecutor(new SetBaseCommand(
|
||||
stateManager, baseService, Clock.systemUTC(), pocketBaseController
|
||||
|
||||
@@ -27,9 +27,13 @@ import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
|
||||
final class NativeRuntime {
|
||||
private static boolean initialized;
|
||||
|
||||
private NativeRuntime() {}
|
||||
|
||||
static void bootstrap() throws Exception {
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
// Paper registries are process-global and reject loading a second time.
|
||||
if (initialized) return;
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
@@ -54,5 +58,6 @@ final class NativeRuntime {
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.IntSupplier;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
|
||||
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.inventory.*;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.*;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.RegisteredListener;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
class SharingCompassEventsTest {
|
||||
static final NamespacedKey KEY = new NamespacedKey("spigotbase", "sharing_compass");
|
||||
final UUID owner = UUID.randomUUID();
|
||||
final Server server = mock(Server.class);
|
||||
final Player player = mock(Player.class);
|
||||
final World world = mock(World.class);
|
||||
final PocketBaseManager pockets = mock(PocketBaseManager.class);
|
||||
final CraftingInventory inventory = mock(CraftingInventory.class);
|
||||
final InventoryView view = mock(InventoryView.class);
|
||||
final IntSupplier roll = mock(IntSupplier.class);
|
||||
ItemStack[] matrix = new ItemStack[9];
|
||||
ItemStack result;
|
||||
ItemStack cursor;
|
||||
final PlayerInventory carried = mock(PlayerInventory.class);
|
||||
final ItemStack[] contents = new ItemStack[41];
|
||||
MockedStatic<Bukkit> bukkit;
|
||||
PocketBaseSharingCompass service;
|
||||
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
@BeforeEach void setup() {
|
||||
bukkit = mockStatic(Bukkit.class);
|
||||
bukkit.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance());
|
||||
bukkit.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE);
|
||||
when(player.getUniqueId()).thenReturn(owner);
|
||||
when(player.getName()).thenReturn("Alice");
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
when(view.getType()).thenReturn(InventoryType.WORKBENCH);
|
||||
when(view.getCursor()).thenAnswer(inv -> cursor);
|
||||
doAnswer(inv -> { cursor = inv.getArgument(0); return null; }).when(view).setCursor(any());
|
||||
when(player.getInventory()).thenReturn(carried);
|
||||
when(carried.getContents()).thenAnswer(inv -> contents.clone());
|
||||
when(player.getItemOnCursor()).thenAnswer(inv -> cursor);
|
||||
doAnswer(inv -> { cursor = inv.getArgument(0); return null; }).when(player).setItemOnCursor(any());
|
||||
when(carried.getItem(any(EquipmentSlot.class))).thenAnswer(inv -> contents[inv.getArgument(0) == EquipmentSlot.HAND ? 0 : 40]);
|
||||
doAnswer(inv -> { contents[inv.getArgument(0) == EquipmentSlot.HAND ? 0 : 40] = inv.getArgument(1); return null; }).when(carried).setItem(any(EquipmentSlot.class), any());
|
||||
when(carried.firstEmpty()).thenAnswer(inv -> { for (int i = 0; i < 36; i++) if (contents[i] == null) return i; return -1; });
|
||||
doAnswer(inv -> { contents[(int) inv.getArgument(0)] = inv.getArgument(1); return null; }).when(carried).setItem(anyInt(), any());
|
||||
when(inventory.getMatrix()).thenAnswer(inv -> Arrays.stream(matrix).map(i -> i == null ? null : i.clone()).toArray(ItemStack[]::new));
|
||||
doAnswer(inv -> { matrix = inv.getArgument(0); return null; }).when(inventory).setMatrix(any(ItemStack[].class));
|
||||
when(inventory.getRecipe()).thenAnswer(inv -> candidateRecipe());
|
||||
when(inventory.getResult()).thenAnswer(inv -> result);
|
||||
doAnswer(inv -> { result = inv.getArgument(0); return null; }).when(inventory).setResult(any());
|
||||
when(pockets.state(owner)).thenReturn(new PocketBaseState(owner, 1, Optional.empty()));
|
||||
service = new PocketBaseSharingCompass(server, pockets, KEY, roll);
|
||||
}
|
||||
@AfterEach void close() { bukkit.close(); }
|
||||
|
||||
@Test void registersTheExactCreationRecipe() {
|
||||
service.registerRecipes();
|
||||
var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class);
|
||||
verify(server, atLeastOnce()).addRecipe(recipes.capture());
|
||||
var recipe = recipes.getAllValues().stream().filter(r -> r instanceof ShapedRecipe shaped && shaped.getKey().equals(KEY)).findFirst().orElseThrow();
|
||||
var shaped = (ShapedRecipe) recipe;
|
||||
assertArrayEquals(new String[]{"DED", "ECE", "DED"}, shaped.getShape());
|
||||
assertEquals(Material.DIAMOND_BLOCK, shaped.getIngredientMap().get('D').getType());
|
||||
assertEquals(Material.ECHO_SHARD, shaped.getIngredientMap().get('E').getType());
|
||||
assertEquals(Material.END_CRYSTAL, shaped.getIngredientMap().get('C').getType());
|
||||
}
|
||||
|
||||
static NamespacedKey field(String suffix) { return new NamespacedKey("spigotbase", "sharing_compass_" + suffix); }
|
||||
static long charges(ItemStack item) { return item.getItemMeta().getPersistentDataContainer().get(field("charges"), PersistentDataType.LONG); }
|
||||
ItemStack compass(long charges) throws Exception {
|
||||
cursor = null;
|
||||
creationMatrix();
|
||||
dispatch(take(ClickType.LEFT));
|
||||
var item = cursor.clone();
|
||||
cursor = null;
|
||||
var meta = item.getItemMeta();
|
||||
meta.getPersistentDataContainer().set(field("charges"), PersistentDataType.LONG, charges);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
void rechargeMatrix(ItemStack item) {
|
||||
for (int i = 0; i < 9; i++) matrix[i] = i == 4 ? item : new ItemStack(Material.ENDER_PEARL, 16);
|
||||
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner));
|
||||
when(inventory.getLocation()).thenReturn(new Location(world, 10, 65, -9));
|
||||
var table = mock(org.bukkit.block.Block.class);
|
||||
when(table.getType()).thenReturn(Material.CRAFTING_TABLE);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(table);
|
||||
when(world.getBlockAt(any(Location.class))).thenReturn(table);
|
||||
}
|
||||
|
||||
@Test void safeRechargePreviewAndCommitRequireExactPearlsAndBoundTableWorld() throws Exception {
|
||||
var item = compass(0);
|
||||
rechargeMatrix(item);
|
||||
preview();
|
||||
assertNotNull(result, "Recharge must have a result preview");
|
||||
verifyNoInteractions(roll);
|
||||
assertEquals(0, charges(matrix[4]));
|
||||
assertEquals(16, matrix[0].getAmount());
|
||||
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(UUID.randomUUID()));
|
||||
preview();
|
||||
assertNull(result);
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertNull(cursor);
|
||||
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner));
|
||||
matrix[0] = new ItemStack(Material.SNOWBALL);
|
||||
preview();
|
||||
assertNull(result);
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertNull(cursor);
|
||||
matrix[0] = new ItemStack(Material.ENDER_PEARL, 16);
|
||||
preview();
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertNotNull(cursor);
|
||||
assertEquals(8, charges(cursor));
|
||||
assertEquals(item.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING),
|
||||
cursor.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING));
|
||||
assertNull(matrix[4]);
|
||||
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount());
|
||||
verify(roll).getAsInt();
|
||||
verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
|
||||
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> rechargeBoundaries() {
|
||||
var cases = new ArrayList<org.junit.jupiter.params.provider.Arguments>();
|
||||
long[] totals = {8, 9, 16, 17, 24, 25, 32, 33, 40, 41, 48, 49, 56, 57, 64, 65, 72, 73, 80, 81, 1024, Long.MAX_VALUE};
|
||||
for (long total : totals) {
|
||||
int chance = total >= 73 ? 10 : 100 - 10 * (int) ((total - 1) / 8);
|
||||
cases.add(org.junit.jupiter.params.provider.Arguments.of(total - 8, chance - 1, true));
|
||||
if (chance < 100) cases.add(org.junit.jupiter.params.provider.Arguments.of(total - 8, chance, false));
|
||||
}
|
||||
return cases.stream();
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.MethodSource("rechargeBoundaries")
|
||||
void eachCommittedRechargeRollsAtCurrentChargeBoundaryAndDestroysOnFailure(long current, int random, boolean succeeds) throws Exception {
|
||||
rechargeMatrix(compass(current));
|
||||
for (int n = 0; n < 3; n++) preview();
|
||||
verifyNoInteractions(roll);
|
||||
when(roll.getAsInt()).thenReturn(random);
|
||||
// A denied native explosion is not retried or replaced and never refunds the failed transaction.
|
||||
when(world.createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean())).thenReturn(false);
|
||||
var event = take(ClickType.LEFT);
|
||||
dispatch(event);
|
||||
assertTrue(event.isCancelled());
|
||||
verify(roll).getAsInt();
|
||||
assertNull(matrix[4], "The input compass leaves the grid on either outcome");
|
||||
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount());
|
||||
if (succeeds) {
|
||||
assertNotNull(cursor);
|
||||
assertEquals(current + 8, charges(cursor));
|
||||
verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
} else {
|
||||
assertNull(cursor, "Failed recharge must destroy the compass, not produce charges");
|
||||
verify(world).createExplosion(player, new Location(world, 10.5, 65.5, -8.5), 6.0F, false, true, false);
|
||||
}
|
||||
dispatch(event);
|
||||
dispatch(take(ClickType.SHIFT_LEFT));
|
||||
verify(roll).getAsInt();
|
||||
if (!succeeds) verify(world, times(1)).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test void rechargeHasRegisteredTableRecipe() {
|
||||
service.registerRecipes();
|
||||
var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class);
|
||||
verify(server, times(2)).addRecipe(recipes.capture());
|
||||
var shaped = recipes.getAllValues().stream().filter(r -> r instanceof ShapedRecipe s && !s.getKey().equals(KEY))
|
||||
.map(ShapedRecipe.class::cast).findFirst().orElseThrow();
|
||||
assertArrayEquals(new String[]{"PPP", "PCP", "PPP"}, shaped.getShape());
|
||||
assertEquals(Material.ENDER_PEARL, shaped.getIngredientMap().get('P').getType());
|
||||
assertEquals(Material.COMPASS, shaped.getIngredientMap().get('C').getType());
|
||||
}
|
||||
|
||||
void successfulTeleport(Location destination) {
|
||||
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> {
|
||||
when(player.getWorld()).thenReturn(destination.getWorld());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
PlayerInteractEvent use(Action action, EquipmentSlot hand) {
|
||||
return new PlayerInteractEvent(player, action, carried.getItem(hand), null, org.bukkit.block.BlockFace.SELF, hand);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
|
||||
void transferredCompassEntersBoundOwnerOnlyConsumesAfterSuccessAndNeverReturns(Action action) throws Exception {
|
||||
var item = compass(2);
|
||||
UUID guest = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(guest);
|
||||
when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest));
|
||||
var destinationWorld = mock(World.class);
|
||||
when(destinationWorld.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(pockets.ownerForPocketWorld(destinationWorld.getUID())).thenReturn(Optional.of(owner));
|
||||
var destination = new Location(destinationWorld, .5, 65, 5.5);
|
||||
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
|
||||
contents[0] = item;
|
||||
when(player.getFallDistance()).thenReturn(12F);
|
||||
// Teleport's native result represents the outcome after PlayerTeleportEvent protection handling.
|
||||
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> {
|
||||
assertEquals(2, charges(contents[0]), "Charge cannot be spent before successful teleport");
|
||||
var teleport = new PlayerTeleportEvent(player, new Location(world, 0, 70, 0), destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
teleport.setCancelled(true);
|
||||
return !teleport.isCancelled();
|
||||
});
|
||||
dispatch(use(action, EquipmentSlot.HAND));
|
||||
assertEquals(2, charges(contents[0]));
|
||||
verify(player).setFallDistance(12F);
|
||||
successfulTeleport(destination);
|
||||
dispatch(use(action, EquipmentSlot.HAND));
|
||||
assertEquals(1, charges(contents[0]), "Guest travel must spend exactly one charge");
|
||||
assertEquals(owner.toString(), contents[0].getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
|
||||
when(player.getWorld()).thenReturn(destinationWorld);
|
||||
dispatch(use(action, EquipmentSlot.HAND));
|
||||
assertEquals(1, charges(contents[0]));
|
||||
verify(player, times(2)).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
}
|
||||
|
||||
@Test void unavailableEmptyAndDeniedTravelNeverSpendAndOffHandCanEnter() throws Exception {
|
||||
contents[40] = compass(1);
|
||||
var destination = new Location(mock(World.class), 0, 65, 0);
|
||||
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.empty());
|
||||
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND));
|
||||
assertEquals(1, charges(contents[40]));
|
||||
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
|
||||
var denied = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND);
|
||||
denied.setUseItemInHand(Event.Result.DENY);
|
||||
dispatch(denied);
|
||||
assertEquals(1, charges(contents[40]));
|
||||
verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
|
||||
successfulTeleport(destination);
|
||||
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND));
|
||||
assertEquals(0, charges(contents[40]));
|
||||
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND));
|
||||
verify(player).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
rechargeMatrix(contents[40]);
|
||||
when(roll.getAsInt()).thenReturn(99);
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertEquals(8, charges(cursor), "Spending back to zero makes recharge 100% safe");
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"inventory", "cursor", "removed", "death", "redirect", "reentrant"})
|
||||
void teleportCallbacksCannotDuplicateResurrectOrOverwriteItems(String callback) throws Exception {
|
||||
var item = compass(8);
|
||||
contents[0] = item;
|
||||
var replacement = new ItemStack(Material.DIAMOND, 7);
|
||||
var destinationWorld = mock(World.class);
|
||||
var destination = new Location(destinationWorld, 0, 65, 0);
|
||||
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
|
||||
var calls = new java.util.concurrent.atomic.AtomicInteger();
|
||||
var drops = new ArrayList<ItemStack>();
|
||||
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> {
|
||||
if (calls.incrementAndGet() == 1) {
|
||||
switch (callback) {
|
||||
case "inventory" -> { contents[12] = contents[0]; contents[0] = replacement; }
|
||||
case "cursor" -> { cursor = contents[0]; contents[0] = replacement; }
|
||||
case "removed" -> contents[0] = replacement;
|
||||
case "death" -> {
|
||||
drops.add(contents[0].clone());
|
||||
dispatch(new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
|
||||
drops, 0, net.kyori.adventure.text.Component.text("death"), true));
|
||||
contents[0] = null; // Native death clears the inventory after the event.
|
||||
when(player.isDead()).thenReturn(true);
|
||||
}
|
||||
case "redirect" -> { /* Protection redirects travel outside the bound Pocket Base. */ }
|
||||
case "reentrant" -> dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
|
||||
default -> throw new AssertionError(callback);
|
||||
}
|
||||
}
|
||||
if (!callback.equals("redirect")) when(player.getWorld()).thenReturn(destinationWorld);
|
||||
return true;
|
||||
});
|
||||
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
|
||||
switch (callback) {
|
||||
case "inventory" -> { assertEquals(replacement, contents[0]); assertEquals(7, charges(contents[12])); }
|
||||
case "cursor" -> { assertEquals(replacement, contents[0]); assertEquals(7, charges(cursor)); }
|
||||
case "removed" -> assertEquals(replacement, contents[0], "Never recreate a removed compass over another item");
|
||||
case "death" -> { assertNull(contents[0], "Never resurrect a discharged/dropped compass"); assertEquals(0, charges(drops.getFirst())); }
|
||||
case "redirect" -> assertEquals(8, charges(contents[0]), "No bound-destination entry means no charge spent");
|
||||
case "reentrant" -> { assertEquals(1, calls.get(), "A teleport callback must not reuse the pending item"); assertEquals(7, charges(contents[0])); }
|
||||
default -> throw new AssertionError(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void visibleInstructionsAndRiskRefreshWithChargesAndKeepOwnerIdentity() throws Exception {
|
||||
var item = compass(0);
|
||||
var lore = item.getItemMeta().lore();
|
||||
assertNotNull(lore);
|
||||
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Charges: 0")), "Current charges must be visible");
|
||||
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Destination: Alice (" + owner + ")")));
|
||||
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Recharge: center of table + 8 ender pearls, inside destination.")));
|
||||
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Next recharge: 100% success / 0% explosion")));
|
||||
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Right-click for entry only; no return trip.")));
|
||||
rechargeMatrix(item);
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertTrue(cursor.getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Charges: 8")));
|
||||
assertTrue(cursor.getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Next recharge: 90% success / 10% explosion")));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void deathDischargesCarriedDroppedAndKeptCompassesWithoutChangingNormalDeath(boolean keepInventory) throws Exception {
|
||||
var item = compass(80);
|
||||
contents[0] = item.clone();
|
||||
contents[40] = item.clone();
|
||||
contents[12] = new ItemStack(Material.DIAMOND, 12);
|
||||
cursor = item.clone();
|
||||
var drops = new ArrayList<ItemStack>(List.of(item.clone(), new ItemStack(Material.DIAMOND, 12)));
|
||||
var death = new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
|
||||
drops, 17, net.kyori.adventure.text.Component.text("ordinary death"), true);
|
||||
death.setKeepInventory(keepInventory);
|
||||
death.getItemsToKeep().add(item.clone());
|
||||
dispatch(death);
|
||||
assertEquals(0, charges(contents[0]), "Death empties carried compasses");
|
||||
assertEquals(0, charges(contents[40]));
|
||||
assertEquals(0, charges(cursor));
|
||||
assertEquals(0, charges(drops.getFirst()));
|
||||
assertEquals(0, charges(death.getItemsToKeep().getFirst()));
|
||||
assertEquals(owner.toString(), drops.getFirst().getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
|
||||
assertEquals(keepInventory, death.getKeepInventory());
|
||||
assertEquals(2, drops.size());
|
||||
assertEquals(12, drops.get(1).getAmount());
|
||||
assertEquals(12, contents[12].getAmount());
|
||||
assertEquals(17, death.getDroppedExp());
|
||||
assertFalse(death.isCancelled());
|
||||
assertTrue(drops.getFirst().getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Charges: 0")));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"missing-id", "bad-id", "short-owner", "bad-owner", "negative", "wrong-charge-type", "missing-charges", "wrong-marker-type", "wrong-version", "stacked", "name-only"})
|
||||
void malformedItemsCannotTravelOrRecharge(String corruption) throws Exception {
|
||||
var item = compass(5);
|
||||
var meta = item.getItemMeta();
|
||||
var data = meta.getPersistentDataContainer();
|
||||
switch (corruption) {
|
||||
case "missing-id" -> data.remove(field("id"));
|
||||
case "bad-id" -> data.set(field("id"), PersistentDataType.STRING, "broken");
|
||||
case "short-owner" -> data.set(field("owner"), PersistentDataType.STRING, "1-1-1-1-1");
|
||||
case "bad-owner" -> data.set(field("owner"), PersistentDataType.STRING, "broken");
|
||||
case "negative" -> data.set(field("charges"), PersistentDataType.LONG, -1L);
|
||||
case "wrong-charge-type" -> data.set(field("charges"), PersistentDataType.STRING, "5");
|
||||
case "missing-charges" -> data.remove(field("charges"));
|
||||
case "wrong-marker-type" -> data.set(KEY, PersistentDataType.INTEGER, 1);
|
||||
case "wrong-version" -> data.set(KEY, PersistentDataType.BYTE, (byte) 2);
|
||||
case "stacked" -> item.setAmount(2);
|
||||
case "name-only" -> { for (var key : new HashSet<>(data.getKeys())) data.remove(key); }
|
||||
default -> throw new AssertionError(corruption);
|
||||
}
|
||||
item.setItemMeta(meta);
|
||||
var destination = new Location(mock(World.class), 0, 65, 0);
|
||||
when(pockets.availablePocketArrival(any())).thenReturn(Optional.of(destination));
|
||||
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenReturn(true);
|
||||
contents[0] = item;
|
||||
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
|
||||
verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
|
||||
rechargeMatrix(item);
|
||||
preview();
|
||||
assertNull(result);
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertNull(cursor);
|
||||
verifyNoInteractions(roll);
|
||||
assertEquals(16, matrix[0].getAmount());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(longs = {Long.MAX_VALUE, Long.MAX_VALUE - 7})
|
||||
void numericOverflowRefusesRechargeBeforeRollingOrPaying(long charges) throws Exception {
|
||||
rechargeMatrix(compass(charges));
|
||||
preview();
|
||||
assertNull(result, "Overflow is invalid arithmetic, not a wrapped free recharge");
|
||||
dispatch(take(ClickType.SHIFT_LEFT));
|
||||
assertNull(contents[0]);
|
||||
assertEquals(charges, charges(matrix[4]));
|
||||
assertEquals(16, matrix[0].getAmount());
|
||||
verifyNoInteractions(roll);
|
||||
}
|
||||
|
||||
@Test void nativeItemPersistenceAcrossFreshServiceAndGuestTransferKeepsSingleIdentityAndCharges() throws Exception {
|
||||
var item = compass(1024);
|
||||
byte[] saved = item.serializeAsBytes();
|
||||
var restored = ItemStack.deserializeBytes(saved);
|
||||
assertEquals(item, restored);
|
||||
assertEquals(1, restored.getMaxStackSize());
|
||||
service = new PocketBaseSharingCompass(server, pockets, KEY, roll);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var destination = new Location(mock(World.class), 0, 65, 0);
|
||||
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
|
||||
successfulTeleport(destination);
|
||||
contents[0] = restored;
|
||||
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
|
||||
assertEquals(1023, charges(contents[0]));
|
||||
var afterLogout = ItemStack.deserializeBytes(contents[0].serializeAsBytes());
|
||||
assertEquals(1023, charges(afterLogout));
|
||||
assertEquals(owner.toString(), afterLogout.getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
|
||||
assertEquals(item.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING),
|
||||
afterLogout.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING));
|
||||
assertEquals(1, Arrays.stream(contents).filter(Objects::nonNull).count());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(value = ClickType.class, names = {"LEFT", "RIGHT", "SHIFT_LEFT", "SHIFT_RIGHT"})
|
||||
void eachResultTakingModePaysForOneRechargeAndNeverDuplicatesDestroyedResults(ClickType click) throws Exception {
|
||||
rechargeMatrix(compass(0));
|
||||
when(roll.getAsInt()).thenReturn(0, 99);
|
||||
var first = take(click);
|
||||
dispatch(first);
|
||||
var charged = click.isShiftClick() ? contents[0] : cursor;
|
||||
assertNotNull(charged);
|
||||
assertEquals(8, charges(charged));
|
||||
dispatch(first);
|
||||
verify(roll).getAsInt();
|
||||
contents[0] = null;
|
||||
cursor = null;
|
||||
matrix[4] = charged;
|
||||
for (int i = 0; i < 5; i++) preview();
|
||||
verify(roll).getAsInt();
|
||||
var second = take(click);
|
||||
dispatch(second);
|
||||
dispatch(second);
|
||||
dispatch(take(click));
|
||||
assertNull(matrix[4]);
|
||||
assertNull(cursor);
|
||||
assertTrue(Arrays.stream(contents).allMatch(Objects::isNull));
|
||||
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(14, matrix[i].getAmount());
|
||||
verify(roll, times(2)).getAsInt();
|
||||
verify(world).createExplosion(player, new Location(world, 10.5, 65.5, -8.5), 6F, false, true, false);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(value = ClickType.class, names = {"NUMBER_KEY", "SWAP_OFFHAND", "DROP", "CONTROL_DROP", "DOUBLE_CLICK", "MIDDLE"})
|
||||
void unsupportedBulkActionsCannotBypassPayment(ClickType click) throws Exception {
|
||||
rechargeMatrix(compass(8));
|
||||
dispatch(take(click));
|
||||
assertEquals(8, charges(matrix[4]));
|
||||
assertEquals(16, matrix[0].getAmount());
|
||||
assertNull(cursor);
|
||||
verifyNoInteractions(roll);
|
||||
}
|
||||
|
||||
@Test void cancelledCraftsAndFullDestinationsDoNotRollOrPay() throws Exception {
|
||||
rechargeMatrix(compass(8));
|
||||
var cancelled = take(ClickType.LEFT);
|
||||
cancelled.setCancelled(true);
|
||||
dispatch(cancelled);
|
||||
cursor = new ItemStack(Material.STONE);
|
||||
dispatch(take(ClickType.LEFT));
|
||||
cursor = null;
|
||||
Arrays.fill(contents, new ItemStack(Material.STONE, 64));
|
||||
dispatch(take(ClickType.SHIFT_LEFT));
|
||||
assertEquals(8, charges(matrix[4]));
|
||||
assertEquals(16, matrix[0].getAmount());
|
||||
verifyNoInteractions(roll);
|
||||
verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(value = Material.class, names = {"PAPER", "ECHO_SHARD"})
|
||||
void ordinaryCompassRecipesAreNotHijacked(Material surround) throws Exception {
|
||||
for (int i = 0; i < 9; i++) matrix[i] = new ItemStack(i == 4 ? Material.COMPASS : surround);
|
||||
result = new ItemStack(surround == Material.PAPER ? Material.MAP : Material.RECOVERY_COMPASS);
|
||||
var vanillaResult = result.clone();
|
||||
preview();
|
||||
assertEquals(vanillaResult, result, "Maps and recovery compasses keep their vanilla recipes");
|
||||
var craft = take(ClickType.LEFT);
|
||||
dispatch(craft);
|
||||
assertFalse(craft.isCancelled());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void nativeExplosionAdapterKeepsCrafterDamageEligibleAndHonorsProtectionCancellation(boolean cancelled) throws Exception {
|
||||
rechargeMatrix(compass(8));
|
||||
var level = mock(net.minecraft.server.level.ServerLevel.class);
|
||||
var nativeWorld = mock(org.bukkit.craftbukkit.CraftWorld.class);
|
||||
var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world");
|
||||
worldField.setAccessible(true);
|
||||
worldField.set(nativeWorld, level);
|
||||
var crafter = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
|
||||
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
|
||||
when(crafter.getHandle()).thenReturn(handle);
|
||||
when(crafter.getUniqueId()).thenReturn(owner);
|
||||
when(crafter.getWorld()).thenReturn(nativeWorld);
|
||||
when(view.getPlayer()).thenReturn(crafter);
|
||||
UUID worldId = world.getUID();
|
||||
var tableBlock = world.getBlockAt(new Location(world, 10, 65, -9));
|
||||
when(nativeWorld.getUID()).thenReturn(worldId);
|
||||
when(nativeWorld.getBlockAt(any(Location.class))).thenReturn(tableBlock);
|
||||
var table = new Location(nativeWorld, 10, 65, -9);
|
||||
when(inventory.getLocation()).thenReturn(table);
|
||||
var damageSources = mock(net.minecraft.world.damagesource.DamageSources.class);
|
||||
var damageSource = mock(net.minecraft.world.damagesource.DamageSource.class);
|
||||
when(level.damageSources()).thenReturn(damageSources);
|
||||
when(damageSources.explosion(handle, handle)).thenReturn(damageSource);
|
||||
var seen = new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.level.ServerExplosion>();
|
||||
when(level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(), anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> {
|
||||
var nativeExplosion = new net.minecraft.world.level.ServerExplosion(level, inv.getArgument(0), inv.getArgument(1), null,
|
||||
new net.minecraft.world.phys.Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)),
|
||||
inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY);
|
||||
java.util.function.Consumer<net.minecraft.world.level.ServerExplosion> configure = inv.getArgument(13);
|
||||
if (configure != null) configure.accept(nativeExplosion);
|
||||
// The world/protection boundary delivers a real cancellable event. The plugin must not retry it.
|
||||
var explosionEvent = new org.bukkit.event.entity.EntityExplodeEvent(crafter, table.clone().add(.5, .5, .5),
|
||||
new ArrayList<>(), 1F, ExplosionResult.DESTROY);
|
||||
new RegisteredListener(new org.bukkit.event.Listener() {}, (ignored, event) -> ((org.bukkit.event.entity.EntityExplodeEvent) event).setCancelled(cancelled),
|
||||
org.bukkit.event.EventPriority.HIGH, mock(Plugin.class), false).callEvent(explosionEvent);
|
||||
nativeExplosion.wasCanceled = explosionEvent.isCancelled();
|
||||
seen.set(nativeExplosion);
|
||||
return nativeExplosion;
|
||||
});
|
||||
doCallRealMethod().when(nativeWorld).createExplosion(any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), any());
|
||||
doCallRealMethod().when(nativeWorld).createExplosion(anyDouble(), anyDouble(), anyDouble(), anyFloat(), anyBoolean(), anyBoolean(), any());
|
||||
doCallRealMethod().when(nativeWorld).createExplosion(any(org.bukkit.entity.Entity.class), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
|
||||
when(roll.getAsInt()).thenReturn(99);
|
||||
var event = take(ClickType.LEFT);
|
||||
dispatch(event);
|
||||
var explosion = seen.get();
|
||||
assertNotNull(explosion);
|
||||
assertFalse(explosion.excludeSourceFromDamage, "The crafter must not be made immune by the native overload");
|
||||
assertEquals(6F, explosion.radius());
|
||||
assertEquals(new net.minecraft.world.phys.Vec3(10.5, 65.5, -8.5), explosion.center());
|
||||
assertSame(handle, explosion.getDirectSourceEntity());
|
||||
assertSame(handle, explosion.getIndirectSourceEntity());
|
||||
assertEquals(cancelled, explosion.wasCanceled);
|
||||
assertNull(matrix[4]);
|
||||
assertNull(cursor);
|
||||
assertEquals(15, matrix[0].getAmount());
|
||||
dispatch(event);
|
||||
verify(roll).getAsInt();
|
||||
verify(level).explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(), anyFloat(), eq(false), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test void sharingAndKeystoneKeepIndependentOwnersRecipesAndReturnRecords() throws Exception {
|
||||
var returns = mock(KeystoneReturnStore.class);
|
||||
var keystoneKey = new NamespacedKey("spigotbase", "pocket_base_keystone");
|
||||
var keystones = new PocketBaseKeystoneService(server, pockets, returns, keystoneKey,
|
||||
java.util.logging.Logger.getAnonymousLogger());
|
||||
contents[0] = compass(2);
|
||||
UUID guest = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(guest);
|
||||
when(pockets.state(guest)).thenReturn(new PocketBaseState(guest, 1, Optional.empty()));
|
||||
World bound = mock(World.class);
|
||||
UUID boundId = UUID.randomUUID();
|
||||
when(bound.getUID()).thenReturn(boundId);
|
||||
when(pockets.ownerForPocketWorld(boundId)).thenReturn(Optional.of(owner));
|
||||
var destination = new Location(bound, .5, 65, 5.5);
|
||||
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
|
||||
successfulTeleport(destination);
|
||||
var sharingUse = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND);
|
||||
dispatchTo(keystones, sharingUse);
|
||||
dispatch(sharingUse);
|
||||
assertEquals(1, charges(contents[0]));
|
||||
verifyNoInteractions(returns);
|
||||
verify(pockets, never()).pocketArrival(any());
|
||||
var sharing = contents[0].clone();
|
||||
|
||||
var keystone = PocketBaseKeystoneService.createKeystone(keystoneKey);
|
||||
contents[0] = keystone;
|
||||
var visitorReturn = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND);
|
||||
dispatchTo(keystones, visitorReturn);
|
||||
dispatch(visitorReturn);
|
||||
verifyNoInteractions(returns); // A holder-bound Keystone cannot leave somebody else's base.
|
||||
verify(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
|
||||
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
var origin = new Location(world, 13, 70, 9, 35, 12);
|
||||
when(player.getLocation()).thenReturn(origin);
|
||||
when(world.getName()).thenReturn("overworld");
|
||||
var ownPocket = new Location(mock(World.class), .5, 65, 5.5);
|
||||
when(pockets.pocketArrival(guest)).thenReturn(ownPocket);
|
||||
successfulTeleport(ownPocket);
|
||||
var ownEntry = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
|
||||
dispatchTo(keystones, ownEntry);
|
||||
dispatch(ownEntry);
|
||||
verify(returns).record(eq(guest), any(KeystoneReturnLocation.class));
|
||||
verify(pockets).pocketArrival(guest);
|
||||
assertEquals(keystone, contents[0]);
|
||||
assertEquals(1, charges(sharing));
|
||||
assertEquals(owner.toString(), sharing.getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
|
||||
|
||||
var recipe = PocketBaseKeystoneService.recipe(keystoneKey, keystone);
|
||||
when(inventory.getRecipe()).thenReturn(recipe);
|
||||
result = keystone.clone();
|
||||
var prepare = new PrepareItemCraftEvent(inventory, view, false);
|
||||
dispatchTo(keystones, prepare);
|
||||
dispatch(prepare);
|
||||
assertEquals(keystone, result);
|
||||
}
|
||||
|
||||
@Test void sharingGuestRetainsOrdinaryInteractionsAndExistingPortalReturn() throws Exception {
|
||||
var settings = PluginSettings.from(Map.of());
|
||||
var controller = new PocketBaseController(server, mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings), pockets, new PluginSettingsProvider(settings),
|
||||
java.util.logging.Logger.getAnonymousLogger());
|
||||
var item = compass(2);
|
||||
contents[0] = item;
|
||||
UUID guest = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(guest);
|
||||
when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest));
|
||||
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner));
|
||||
when(pockets.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||
var block = mock(org.bukkit.block.Block.class);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(block.getX()).thenReturn(10);
|
||||
when(block.getY()).thenReturn(65);
|
||||
when(block.getZ()).thenReturn(10);
|
||||
var place = new org.bukkit.event.block.BlockPlaceEvent(block, mock(org.bukkit.block.BlockState.class), block,
|
||||
new ItemStack(Material.STONE), player, true, EquipmentSlot.HAND);
|
||||
dispatchTo(controller, place);
|
||||
dispatch(place);
|
||||
assertFalse(place.isCancelled());
|
||||
var breaking = new org.bukkit.event.block.BlockBreakEvent(block, player);
|
||||
dispatchTo(controller, breaking);
|
||||
dispatch(breaking);
|
||||
assertFalse(breaking.isCancelled());
|
||||
var open = new InventoryOpenEvent(view);
|
||||
dispatchTo(controller, open);
|
||||
dispatch(open);
|
||||
assertFalse(open.isCancelled());
|
||||
contents[0] = null;
|
||||
var interaction = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
|
||||
dispatchTo(controller, interaction);
|
||||
dispatch(interaction);
|
||||
assertNotEquals(Event.Result.DENY, interaction.useItemInHand());
|
||||
contents[0] = item;
|
||||
var pocketReturn = new PocketPortalLocation(world.getUID(), "pocket", -2, 65, 0, PocketPortalAxis.X);
|
||||
when(pockets.returnPortalIsIntact(owner)).thenReturn(true);
|
||||
when(pockets.returnPortal(owner)).thenReturn(pocketReturn);
|
||||
var outside = mock(World.class);
|
||||
var spawn = new Location(outside, 3, 80, 3);
|
||||
when(server.getWorlds()).thenReturn(List.of(outside));
|
||||
when(outside.getSpawnLocation()).thenReturn(spawn);
|
||||
successfulTeleport(spawn);
|
||||
var move = new org.bukkit.event.player.PlayerMoveEvent(player,
|
||||
new Location(world, .5, 66, 5.5), new Location(world, .5, 66, .5));
|
||||
dispatchTo(controller, move);
|
||||
dispatch(move);
|
||||
verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
assertEquals(2, charges(contents[0]), "Existing portal returns do not spend compass charges");
|
||||
}
|
||||
|
||||
@Test void failedRechargeConsumesBeforeSynchronousDeathAndNeverRestoresDestroyedCompass() throws Exception {
|
||||
when(roll.getAsInt()).thenReturn(99);
|
||||
contents[0] = compass(40);
|
||||
// compass() uses the crafting fixture; rebuild the transaction after creating carried items.
|
||||
rechargeMatrix(compass(8));
|
||||
var drops = new ArrayList<ItemStack>();
|
||||
when(world.createExplosion(eq(player), any(Location.class), eq(6F), eq(false), eq(true), eq(false))).thenAnswer(inv -> {
|
||||
assertNull(matrix[4], "The destroyed compass must be gone before explosion callbacks");
|
||||
assertNull(result);
|
||||
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount());
|
||||
drops.add(contents[0].clone());
|
||||
dispatch(new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
|
||||
drops, 0, net.kyori.adventure.text.Component.text("exploded"), true));
|
||||
contents[0] = null;
|
||||
return true;
|
||||
});
|
||||
var craft = take(ClickType.SHIFT_LEFT);
|
||||
dispatch(craft);
|
||||
dispatch(craft);
|
||||
assertNull(cursor);
|
||||
assertTrue(Arrays.stream(contents).allMatch(Objects::isNull));
|
||||
assertEquals(0, charges(drops.getFirst()));
|
||||
assertEquals(1, drops.size());
|
||||
verify(roll).getAsInt();
|
||||
}
|
||||
|
||||
@Test void cancelledDeathLeavesCarriedCompassesAndDropsAlone() throws Exception {
|
||||
contents[0] = compass(80);
|
||||
var drops = new ArrayList<ItemStack>(List.of(contents[0].clone()));
|
||||
var event = new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
|
||||
drops, 10, net.kyori.adventure.text.Component.text("prevented"), true);
|
||||
event.setCancelled(true);
|
||||
dispatch(event);
|
||||
assertEquals(80, charges(contents[0]));
|
||||
assertEquals(80, charges(drops.getFirst()));
|
||||
}
|
||||
|
||||
void creationMatrix() {
|
||||
for (int i = 0; i < 9; i++) matrix[i] = new ItemStack(i == 4 ? Material.END_CRYSTAL : i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD);
|
||||
}
|
||||
void dispatch(Event event) throws Exception { dispatchTo(service, event); }
|
||||
void dispatchTo(org.bukkit.event.Listener listener, Event event) throws Exception {
|
||||
for (var method : listener.getClass().getMethods()) {
|
||||
var annotation = method.getAnnotation(EventHandler.class);
|
||||
if (annotation != null && method.getParameterTypes()[0].isInstance(event)) {
|
||||
new RegisteredListener(listener, (ignored, e) -> {
|
||||
try { method.invoke(listener, e); } catch (ReflectiveOperationException ex) { throw new org.bukkit.event.EventException(ex); }
|
||||
}, annotation.priority(), mock(Plugin.class), annotation.ignoreCancelled()).callEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapedRecipe candidateRecipe() {
|
||||
if (matrix[4] != null && matrix[4].getType() == Material.COMPASS && matrix[0] != null) {
|
||||
if (matrix[0].getType() == Material.PAPER) {
|
||||
return new ShapedRecipe(NamespacedKey.minecraft("map"), new ItemStack(Material.MAP));
|
||||
}
|
||||
if (matrix[0].getType() == Material.ECHO_SHARD) {
|
||||
return new ShapedRecipe(NamespacedKey.minecraft("recovery_compass"), new ItemStack(Material.RECOVERY_COMPASS));
|
||||
}
|
||||
}
|
||||
boolean creation = matrix[4] != null && matrix[4].getType() == Material.END_CRYSTAL;
|
||||
return new ShapedRecipe(creation ? KEY : field("recharge"), new ItemStack(Material.COMPASS));
|
||||
}
|
||||
void preview() throws Exception {
|
||||
// Vanilla resolves the recipe/result before PrepareItemCraftEvent is delivered.
|
||||
result = candidateRecipe().getResult();
|
||||
dispatch(new PrepareItemCraftEvent(inventory, view, false));
|
||||
}
|
||||
CraftItemEvent take(ClickType click) throws Exception {
|
||||
preview();
|
||||
return new CraftItemEvent(candidateRecipe(), view, InventoryType.SlotType.RESULT, 0, click,
|
||||
click.isShiftClick() ? InventoryAction.MOVE_TO_OTHER_INVENTORY : InventoryAction.PICKUP_ALL);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void suppressedRecipePreviewAndResultCannotBeResurrected(boolean recharge) throws Exception {
|
||||
if (recharge) rechargeMatrix(compass(8)); else creationMatrix();
|
||||
result = null; // A preceding protection listener removed the vanilla preview.
|
||||
dispatch(new PrepareItemCraftEvent(inventory, view, false));
|
||||
assertNull(result, "Do not restore a result suppressed by another plugin");
|
||||
var event = take(ClickType.LEFT);
|
||||
result = null; // Also recheck the current result at transaction commit.
|
||||
dispatch(event);
|
||||
assertNull(cursor);
|
||||
assertNotNull(matrix[4]);
|
||||
verifyNoInteractions(roll);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void staleOwnedRecipeCannotFallThroughToVanillaAfterIngredientsChange(boolean recharge) throws Exception {
|
||||
if (recharge) rechargeMatrix(compass(8)); else creationMatrix();
|
||||
var event = take(ClickType.LEFT);
|
||||
assertNotNull(result);
|
||||
matrix[4] = null; // Another listener changed the matrix after native recipe resolution.
|
||||
dispatch(event);
|
||||
assertTrue(event.isCancelled(), "An owned result must never fall through to vanilla payment");
|
||||
assertNull(result, "Remove a stale owned preview rather than returning an unpaid result");
|
||||
assertNull(cursor);
|
||||
verifyNoInteractions(roll);
|
||||
}
|
||||
|
||||
@Test void foreignRecipeWithSameIngredientsIsNotReplacedOrManuallyCrafted() throws Exception {
|
||||
creationMatrix();
|
||||
var foreign = new ShapedRecipe(new NamespacedKey("other", "recipe"), new ItemStack(Material.DIAMOND));
|
||||
when(inventory.getRecipe()).thenReturn(foreign);
|
||||
result = foreign.getResult();
|
||||
dispatch(new PrepareItemCraftEvent(inventory, view, false));
|
||||
assertEquals(Material.DIAMOND, result.getType());
|
||||
var event = new CraftItemEvent(foreign, view, InventoryType.SlotType.RESULT, 0,
|
||||
ClickType.LEFT, InventoryAction.PICKUP_ALL);
|
||||
dispatch(event);
|
||||
assertFalse(event.isCancelled());
|
||||
assertNull(cursor);
|
||||
assertNotNull(matrix[4]);
|
||||
}
|
||||
|
||||
@Test void takingCreationPaysOnceBindsCrafterAndRejectsLockedAndCancelledCrafts() throws Exception {
|
||||
creationMatrix();
|
||||
preview();
|
||||
var cancelled = take(ClickType.LEFT);
|
||||
cancelled.setCancelled(true);
|
||||
dispatch(cancelled);
|
||||
assertNull(cursor);
|
||||
assertEquals(1, matrix[0].getAmount());
|
||||
var event = take(ClickType.LEFT);
|
||||
dispatch(event);
|
||||
assertNotNull(cursor, "Taking the result must commit one paid compass");
|
||||
assertTrue(event.isCancelled(), "Vanilla result-taking must not also produce an item");
|
||||
assertTrue(Arrays.stream(matrix).allMatch(Objects::isNull));
|
||||
assertEquals(owner.toString(), cursor.getItemMeta().getPersistentDataContainer().get(new NamespacedKey("spigotbase", "sharing_compass_owner"), PersistentDataType.STRING));
|
||||
var paid = cursor.clone();
|
||||
dispatch(take(ClickType.SHIFT_LEFT));
|
||||
assertTrue(Arrays.stream(contents).allMatch(Objects::isNull));
|
||||
assertEquals(paid, cursor);
|
||||
cursor = null;
|
||||
creationMatrix();
|
||||
when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner));
|
||||
dispatch(take(ClickType.LEFT));
|
||||
assertNull(cursor);
|
||||
assertEquals(1, matrix[4].getAmount());
|
||||
}
|
||||
|
||||
@Test void unlockedOwnerGetsEmptyMetadataBoundPreviewFromExactRecipeOnly() throws Exception {
|
||||
creationMatrix();
|
||||
preview();
|
||||
assertNotNull(result, "The approved recipe must preview a sharing compass");
|
||||
assertEquals(Material.COMPASS, result.getType());
|
||||
var data = result.getItemMeta().getPersistentDataContainer();
|
||||
assertEquals((byte) 1, data.get(KEY, PersistentDataType.BYTE));
|
||||
assertEquals(owner.toString(), data.get(new NamespacedKey("spigotbase", "sharing_compass_owner"), PersistentDataType.STRING));
|
||||
assertEquals(0L, data.get(new NamespacedKey("spigotbase", "sharing_compass_charges"), PersistentDataType.LONG));
|
||||
verifyNoInteractions(roll);
|
||||
assertEquals(1, matrix[4].getAmount());
|
||||
matrix[0] = new ItemStack(Material.DIAMOND);
|
||||
preview();
|
||||
assertNull(result);
|
||||
creationMatrix();
|
||||
when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner));
|
||||
preview();
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,15 @@ class BaseIdentificationWiringTest {
|
||||
doCallRealMethod().when(plugin).onDisable();
|
||||
try (var validator = mockStatic(PluginSettingsValidator.class);
|
||||
var keystones = mockConstruction(PocketBaseKeystoneService.class);
|
||||
var sharing = mockConstruction(PocketBaseSharingCompass.class);
|
||||
var packets = mockConstruction(NativeBaseLabelPackets.class);
|
||||
var labels = mockConstruction(BukkitBaseLabels.class)) {
|
||||
validator.when(() -> PluginSettingsValidator.validateMaterials(any())).thenAnswer(call -> call.getArgument(0));
|
||||
plugin.onEnable();
|
||||
assertEquals(1, keystones.constructed().size());
|
||||
assertEquals(1, sharing.constructed().size(), "Enable must install guest compasses alongside Keystones");
|
||||
verify(sharing.constructed().getFirst()).registerRecipes();
|
||||
verify(manager).registerEvents(sharing.constructed().getFirst(), plugin);
|
||||
verify(manager, never()).disablePlugin(plugin);
|
||||
var tasks = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong());
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.jar.JarFile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/** Distribution branding is deliberately independent of persistent/runtime plugin identity. */
|
||||
class PurpurDistributionTest {
|
||||
@Test
|
||||
void packagesJava25UnderNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
|
||||
Path artifact = Path.of(System.getProperty("distribution.jar"));
|
||||
String version = System.getProperty("distribution.version");
|
||||
assertEquals("purpur-base-" + version + ".jar", artifact.getFileName().toString());
|
||||
try (var jar = new JarFile(artifact.toFile())) {
|
||||
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
|
||||
assertEquals(version, metadata.get("version"));
|
||||
assertEquals("SpigotBase", metadata.get("name"), "Do not orphan the existing plugin data folder or default namespace");
|
||||
assertEquals("games.dmg.spigotbase.SpigotBasePlugin", metadata.get("main"));
|
||||
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotbase.admin"));
|
||||
try (var bytecode = new DataInputStream(jar.getInputStream(
|
||||
jar.getJarEntry("games/dmg/spigotbase/SpigotBasePlugin.class")))) {
|
||||
assertEquals(0xCAFEBABE, bytecode.readInt());
|
||||
bytecode.readUnsignedShort();
|
||||
assertEquals(69, bytecode.readUnsignedShort(), "Plugin bytecode must target Java 25");
|
||||
}
|
||||
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|
||||
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
|
||||
String build = Files.readString(Path.of("build.gradle.kts"));
|
||||
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
|
||||
assertFalse(build.contains("org.spigotmc:spigot-api"));
|
||||
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
|
||||
String content = Files.readString(Path.of(".gitea/workflows", workflow));
|
||||
assertTrue(content.contains("java-version: 25"));
|
||||
assertTrue(content.contains("purpur-base-"), workflow + " must publish the renamed distribution");
|
||||
assertFalse(content.contains("spigot-base-"), workflow + " must not look for the old JAR filename");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SharingCompassDestinationTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void destinationUsesPersistedUnlockAndLoadedWorldWithoutCreatingMissingWorlds() throws Exception {
|
||||
UUID owner = UUID.randomUUID();
|
||||
var repository = new YamlPocketBaseRepository(directory.resolve("pockets.yml"));
|
||||
var states = new PocketBaseStateManager(repository);
|
||||
states.updateAndSave(owner, state -> state.withLevel(1));
|
||||
var server = mock(Server.class);
|
||||
var settings = new PluginSettingsProvider(PluginSettings.from(Map.of()));
|
||||
var worlds = new PocketBaseWorldService(server, settings);
|
||||
var manager = new PocketBaseManager(new PocketBaseStateManager(repository), worlds, settings);
|
||||
assertTrue(manager.availablePocketArrival(owner).isEmpty());
|
||||
verify(server, never()).createWorld(any());
|
||||
var world = mock(World.class);
|
||||
when(server.getWorld("spigot_base_pocket_" + owner.toString().replace("-", ""))).thenReturn(world);
|
||||
assertEquals(new Location(world, .5, 65, 5.5, 180F, 0F), manager.availablePocketArrival(owner).orElseThrow());
|
||||
assertTrue(manager.availablePocketArrival(UUID.randomUUID()).isEmpty());
|
||||
verify(server, never()).createWorld(any());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user