feat(carry): carry animals and villagers
Release / release (push) Successful in 5m19s
CI / build (push) Successful in 1m9s

This commit is contained in:
dmg committed 2026-09-22 22:34:58 -04:00
1 parent 23964f7695
commit 53f9c817cf
15 files changed
+1691 -6

No files matched your search

+8 -4
View File
@@ -1,6 +1,6 @@
# Purpur Carry
Purpur Carry is a Purpur 26.2 plugin for native animal and Villager carrying on SoMC. The initial build/release enabler intentionally packages no gameplay; the approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-carry/index.md).
Purpur Carry is a Purpur 26.2 plugin for native animal and Villager carrying on SoMC. The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-carry/index.md).
## Requirements
@@ -14,11 +14,15 @@ Purpur Carry is a Purpur 26.2 plugin for native animal and Villager carrying on
./gradlew clean check jar
```
The command compiles with fatal warnings, runs the Java distribution tests and TypeScript release-tool tests, then writes `build/libs/purpur-carry-<version>.jar`. Purpur is compile-only and is not bundled.
The command compiles with fatal warnings, runs Java domain/adapter/distribution tests, the checksum-pinned Purpur event/command suite, and TypeScript release-tool tests, then writes `build/libs/purpur-carry-<version>.jar`. Purpur is compile-only and is not bundled. The pinned runtime is extracted in patch-only mode; tests do not start a server, open a port, create a world, or accept the EULA.
## Current behavior
## Carrying
The enabler loads as `PurpurCarry` but registers no command, permission, listener, configuration, persistence, or gameplay behavior. [US-001](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-carry/us-001-carry-animals-and-villagers.md) owns `/carry`, `purpurcarry.use`, ordinary-Lead interaction, native passenger carrying, release, and cleanup.
Players with the default-true `purpurcarry.use` permission can hold an ordinary Lead and sneak-right-click one eligible live non-hostile animal or Villager. Owned tameables accept only their owner; hostile mobs, bosses, Wandering Traders, leashed entities, existing vehicles/passengers, and unavailable participants are rejected without replacing their native interaction.
A successful action uses the same live entity as the player's one native passenger. The Lead is reusable and unchanged. Right-clicking air or a block with a main-hand Lead requests native release. Native placement, motion, falling, damage, suffocation, and environmental hazards remain authoritative; Carry does not serialize, clone, rescue-teleport, eject, grant invulnerability, or rewrite entity state.
Disconnects, deaths/removals, accepted teleports/portal transitions, carrier vehicle entry, native dismounts, and plugin disable reconcile only the owned relationship. Native cancellation is respected, and refused dismounts are reported instead of forced. `/carry` and `/carry help` explain the controls and hazards; completion suggests only `help` to permitted senders.
## Releases
+59
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -28,6 +31,7 @@ dependencies {
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
testImplementation("org.yaml:snakeyaml:2.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@@ -48,6 +52,61 @@ val releaseToolCheck = tasks.register<Exec>("releaseToolCheck") {
}
tasks.check { dependsOn(releaseToolCheck) }
// Exercise actual Purpur event dispatch and command registration without starting a server.
val carryRuntime = layout.buildDirectory.dir("carry-runtime")
val downloadCarryRuntime = tasks.register("downloadCarryRuntime") {
val launcher = carryRuntime.map { it.file("purpur-26.2-2618.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()
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"
}
file.writeBytes(bytes)
}
}
val prepareCarryRuntime = tasks.register<JavaExec>("prepareCarryRuntime") {
dependsOn(downloadCarryRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(carryRuntime.map { it.file("purpur-26.2-2618.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(carryRuntime)
outputs.dir(carryRuntime.map { it.dir("versions") })
outputs.dir(carryRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(carryRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareCarryRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeCarryTest = tasks.register<Test>("nativeCarryTest") {
description = "Runs registered Purpur carry event and command regressions without a server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(carryRuntime)
}
tasks.check { dependsOn(nativeCarryTest) }
val pluginVersion = version
tasks.processResources {
@@ -0,0 +1,52 @@
package games.dmg.purpurcarry;
import java.util.List;
import java.util.Locale;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
public final class CarryCommand implements CommandExecutor, TabCompleter {
static final String HELP = "Use a Lead: sneak-right-click one non-hostile animal or Villager "
+ "(only your owned tameables; not leashed or riding) to carry one native passenger; "
+ "right-click with the Lead to release. "
+ "Normal placement, falling, damage, suffocation, and environmental hazards apply.";
private static final String USAGE = "Usage: /carry [help]";
@Override
public boolean onCommand(
CommandSender sender,
Command command,
String label,
String[] arguments
) {
if (!sender.hasPermission("purpurcarry.use")) {
sender.sendMessage("You do not have permission to use Carry.");
return true;
}
if (arguments.length == 0
|| (arguments.length == 1 && arguments[0].equalsIgnoreCase("help"))) {
sender.sendMessage(HELP);
} else {
sender.sendMessage(USAGE);
}
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!sender.hasPermission("purpurcarry.use") || arguments.length != 1) {
return List.of();
}
String input = arguments[0].toLowerCase(Locale.ROOT);
return !input.equals("help") && "help".startsWith(input)
? List.of("help")
: List.of();
}
}
@@ -0,0 +1,302 @@
package games.dmg.purpurcarry;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.logging.Logger;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Animals;
import org.bukkit.entity.Boss;
import org.bukkit.entity.Enemy;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.entity.Tameable;
import org.bukkit.entity.Villager;
import org.bukkit.entity.WanderingTrader;
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.EntityDeathEvent;
import org.bukkit.event.entity.EntityDismountEvent;
import org.bukkit.event.entity.EntityMountEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.inventory.EquipmentSlot;
/** Server-thread Bukkit boundary for native carrying. */
public final class CarryController implements Listener, CarryService.Port, AutoCloseable {
private final Server server;
private final Logger logger;
private final Consumer<Runnable> nextTick;
private final CarryService service;
private record Interaction(UUID carrier, UUID carried) {}
private final java.util.Set<Interaction> recentInteractions = new java.util.HashSet<>();
private final java.util.Set<UUID> queuedReconciliation = new java.util.HashSet<>();
private boolean closed;
public CarryController(Server server, Logger logger) {
this(server, logger, Runnable::run);
}
CarryController(Server server, Logger logger, Consumer<Runnable> nextTick) {
this.server = Objects.requireNonNull(server, "server");
this.logger = Objects.requireNonNull(logger, "logger");
this.nextTick = Objects.requireNonNull(nextTick, "nextTick");
this.service = new CarryService(this);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEntityInteract(PlayerInteractEntityEvent event) {
if (closed || event.isCancelled() || event.getHand() != EquipmentSlot.HAND) {
return;
}
Player carrier = event.getPlayer();
Entity target = event.getRightClicked();
Interaction interaction = new Interaction(
carrier.getUniqueId(), target.getUniqueId());
if (recentInteractions.contains(interaction)) {
event.setCancelled(true);
return;
}
if (!canRequest(carrier, target)) {
return;
}
recentInteractions.add(interaction);
nextTick.accept(() -> recentInteractions.remove(interaction));
CarryService.Start result = service.start(
interaction.carrier(), interaction.carried());
if (result == CarryService.Start.STARTED) {
event.setCancelled(true);
carrier.sendMessage("Carry started with native passenger movement. "
+ "Right-click with a Lead to release; normal hazards apply.");
} else if (result == CarryService.Start.REFUSED) {
if (service.isCarrier(carrier.getUniqueId())) {
event.setCancelled(true);
}
carrier.sendMessage("The server refused native carrying; no entity was replaced.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onLeadInteract(PlayerInteractEvent event) {
if (closed || event.isCancelled() || event.getHand() != EquipmentSlot.HAND
|| (event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK)
|| event.getItem() == null || event.getItem().getType() != Material.LEAD) {
return;
}
Player carrier = event.getPlayer();
if (!service.isCarrier(carrier.getUniqueId())) {
return;
}
CarryService.Stop result = service.stopCarrier(carrier.getUniqueId());
if (result == CarryService.Stop.STOPPED) {
event.setCancelled(true);
carrier.sendMessage("Carry ended. Native placement and normal hazards apply.");
} else if (result == CarryService.Stop.REFUSED) {
carrier.sendMessage("The server refused the dismount; carrying remains active.");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
cleanupTerminal(event.getPlayer(), "disconnect");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onDeath(EntityDeathEvent event) {
cleanupTerminal(event.getEntity(), "death");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onRemove(EntityRemoveEvent event) {
cleanupTerminal(event.getEntity(), "removal");
}
private void cleanupTerminal(Entity entity, String cause) {
UUID participant = entity.getUniqueId();
if (service.involved(participant) && !service.cleanup(participant)) {
logger.severe("Carry " + cause + " for " + participant
+ " remains natively attached because dismounting was refused.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEntityDismount(EntityDismountEvent event) {
UUID participant = event.getEntity().getUniqueId();
if (closed || event.isCancelled() || !service.involved(participant)
|| !queuedReconciliation.add(participant)) {
return;
}
nextTick.accept(() -> {
queuedReconciliation.remove(participant);
if (!closed && !event.isCancelled()) {
service.reconcile(participant);
}
});
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onEntityMount(EntityMountEvent event) {
UUID carriedId = event.getEntity().getUniqueId();
UUID carrierId = event.getMount().getUniqueId();
if (service.isMounting(carrierId, carriedId)) {
return;
}
if (service.involved(carrierId) || service.involved(carriedId)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEntityTeleport(EntityTeleportEvent event) {
if (closed || event.isCancelled()
|| !service.involved(event.getEntity().getUniqueId())) {
return;
}
if (!service.cleanup(event.getEntity().getUniqueId())) {
event.setCancelled(true);
logger.warning("The server refused native dismount before entity teleport for "
+ event.getEntity().getUniqueId() + "; the teleport was cancelled.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onVehicleEnter(VehicleEnterEvent event) {
if (closed || event.isCancelled() || !(event.getEntered() instanceof Player player)
|| !service.isCarrier(player.getUniqueId())) {
return;
}
if (!service.cleanup(player.getUniqueId())) {
event.setCancelled(true);
player.sendMessage("The server refused the dismount; carrying remains active.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerTeleport(PlayerTeleportEvent event) {
if (closed || event.isCancelled()) {
return;
}
Player player = event.getPlayer();
if (service.involved(player.getUniqueId())
&& !service.cleanup(player.getUniqueId())) {
event.setCancelled(true);
player.sendMessage("The server refused the dismount; carrying remains active.");
}
}
private boolean canRequest(Player carrier, Entity target) {
if (!carrier.hasPermission("purpurcarry.use")
|| carrier.getGameMode() != GameMode.SURVIVAL
|| !carrier.isSneaking()
|| carrier.getInventory().getItemInMainHand().getType() != Material.LEAD
|| !carrier.isOnline() || !carrier.isValid() || carrier.isDead()
|| carrier.isInsideVehicle() || !carrier.getPassengers().isEmpty()) {
return false;
}
CarryEligibility.Kind kind = target instanceof Villager
? CarryEligibility.Kind.VILLAGER
: target instanceof Animals
? CarryEligibility.Kind.ANIMAL
: CarryEligibility.Kind.OTHER;
UUID owner = null;
if (target instanceof Tameable tameable && tameable.getOwner() != null) {
owner = tameable.getOwner().getUniqueId();
}
boolean leashed = target instanceof Mob mob && mob.isLeashed();
CarryEligibility.Target facts = new CarryEligibility.Target(
kind,
target.isValid() && !target.isDead(),
target instanceof Enemy,
target instanceof Boss,
target instanceof WanderingTrader,
target.isInsideVehicle(),
!target.getPassengers().isEmpty(),
leashed,
owner
);
return CarryEligibility.eligible(carrier.getUniqueId(), facts);
}
@Override
public boolean mount(UUID carrierId, UUID carriedId) {
Entity carrier = server.getEntity(carrierId);
Entity carried = server.getEntity(carriedId);
return carrier instanceof Player && carried != null
&& carrier.addPassenger(carried) && attached(carrierId, carriedId);
}
@Override
public boolean attached(UUID carrierId, UUID carriedId) {
Entity carrier = server.getEntity(carrierId);
Entity carried = resolveCarried(carrierId, carriedId);
if (!(carrier instanceof Player) || carried == null) {
return false;
}
List<Entity> passengers = carrier.getPassengers();
return carried.getVehicle() != null
&& carried.getVehicle().getUniqueId().equals(carrierId)
&& passengers.size() == 1
&& passengers.getFirst().getUniqueId().equals(carriedId)
&& carried.getPassengers().isEmpty()
&& !carrier.isInsideVehicle()
&& Objects.equals(carrier.getWorld(), carried.getWorld());
}
@Override
public boolean linked(UUID carrierId, UUID carriedId) {
Entity carried = resolveCarried(carrierId, carriedId);
return carried != null && carried.getVehicle() != null
&& carried.getVehicle().getUniqueId().equals(carrierId);
}
@Override
public boolean dismount(UUID carrierId, UUID carriedId) {
Entity carried = resolveCarried(carrierId, carriedId);
if (carried == null || carried.getVehicle() == null
|| !carried.getVehicle().getUniqueId().equals(carrierId)) {
return true;
}
carried.leaveVehicle();
return carried.getVehicle() == null
|| !carried.getVehicle().getUniqueId().equals(carrierId);
}
private Entity resolveCarried(UUID carrierId, UUID carriedId) {
Entity carried = server.getEntity(carriedId);
if (carried != null) {
return carried;
}
Entity carrier = server.getEntity(carrierId);
if (carrier == null) {
return null;
}
return carrier.getPassengers().stream()
.filter(passenger -> passenger.getUniqueId().equals(carriedId))
.findFirst()
.orElse(null);
}
@Override
public void close() {
closed = true;
recentInteractions.clear();
queuedReconciliation.clear();
int unresolved = service.close();
if (unresolved != 0) {
logger.severe("Carry disable left " + unresolved
+ " native relationship(s) attached because dismounting was refused.");
}
}
}
@@ -0,0 +1,73 @@
package games.dmg.purpurcarry;
import java.util.UUID;
public final class CarryEligibility {
public enum Kind { ANIMAL, VILLAGER, OTHER }
public record Target(
Kind kind,
boolean alive,
boolean hostile,
boolean boss,
boolean wanderingTrader,
boolean riding,
boolean passengers,
boolean leashed,
UUID owner
) {
public Target withAlive(boolean value) {
return new Target(kind, value, hostile, boss, wanderingTrader,
riding, passengers, leashed, owner);
}
public Target withHostile(boolean value) {
return new Target(kind, alive, value, boss, wanderingTrader,
riding, passengers, leashed, owner);
}
public Target withBoss(boolean value) {
return new Target(kind, alive, hostile, value, wanderingTrader,
riding, passengers, leashed, owner);
}
public Target withWanderingTrader(boolean value) {
return new Target(kind, alive, hostile, boss, value,
riding, passengers, leashed, owner);
}
public Target withRiding(boolean value) {
return new Target(kind, alive, hostile, boss, wanderingTrader,
value, passengers, leashed, owner);
}
public Target withPassengers(boolean value) {
return new Target(kind, alive, hostile, boss, wanderingTrader,
riding, value, leashed, owner);
}
public Target withLeashed(boolean value) {
return new Target(kind, alive, hostile, boss, wanderingTrader,
riding, passengers, value, owner);
}
public Target withOwner(UUID value) {
return new Target(kind, alive, hostile, boss, wanderingTrader,
riding, passengers, leashed, value);
}
}
private CarryEligibility() {}
public static boolean eligible(UUID requester, Target target) {
return (target.kind() == Kind.ANIMAL || target.kind() == Kind.VILLAGER)
&& target.alive()
&& !target.hostile()
&& !target.boss()
&& !target.wanderingTrader()
&& !target.riding()
&& !target.passengers()
&& !target.leashed()
&& (target.owner() == null || target.owner().equals(requester));
}
}
@@ -0,0 +1,130 @@
package games.dmg.purpurcarry;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/** Server-thread relationship state; the port performs only native entity operations. */
public final class CarryService {
public enum Start { STARTED, BUSY, REFUSED }
public enum Stop { STOPPED, NONE, REFUSED }
public interface Port {
boolean mount(UUID carrier, UUID carried);
boolean attached(UUID carrier, UUID carried);
boolean linked(UUID carrier, UUID carried);
boolean dismount(UUID carrier, UUID carried);
}
private record Relationship(UUID carrier, UUID carried) {}
private final Port port;
private final Map<UUID, Relationship> relationships = new HashMap<>();
private final java.util.Set<UUID> mounting = new java.util.HashSet<>();
private boolean closed;
public CarryService(Port port) {
this.port = java.util.Objects.requireNonNull(port, "port");
}
public Start start(UUID carrier, UUID carried) {
if (closed) {
return Start.REFUSED;
}
if (carrier.equals(carried) || involved(carrier) || involved(carried)
|| mounting.contains(carrier) || mounting.contains(carried)) {
return Start.BUSY;
}
mounting.add(carrier);
mounting.add(carried);
boolean mounted;
try {
mounted = port.mount(carrier, carried);
} finally {
mounting.remove(carrier);
mounting.remove(carried);
}
if ((!mounted && !port.linked(carrier, carried))
|| !port.attached(carrier, carried)) {
if (port.linked(carrier, carried)) {
port.dismount(carrier, carried);
if (port.linked(carrier, carried)) {
remember(new Relationship(carrier, carried));
}
}
return Start.REFUSED;
}
remember(new Relationship(carrier, carried));
return Start.STARTED;
}
public Stop stopCarrier(UUID carrier) {
Relationship relationship = relationships.get(carrier);
if (relationship == null || !relationship.carrier().equals(carrier)) {
return Stop.NONE;
}
return stop(relationship);
}
public boolean cleanup(UUID participant) {
Relationship relationship = relationships.get(participant);
return relationship == null || stop(relationship) == Stop.STOPPED;
}
private Stop stop(Relationship relationship) {
if (!port.dismount(relationship.carrier(), relationship.carried())
&& port.linked(relationship.carrier(), relationship.carried())) {
return Stop.REFUSED;
}
if (port.linked(relationship.carrier(), relationship.carried())) {
return Stop.REFUSED;
}
forget(relationship);
return Stop.STOPPED;
}
public void reconcile(UUID participant) {
Relationship relationship = relationships.get(participant);
if (relationship != null
&& !port.linked(relationship.carrier(), relationship.carried())) {
forget(relationship);
}
}
public boolean isMounting(UUID carrier, UUID carried) {
return mounting.contains(carrier) && mounting.contains(carried);
}
private void forget(Relationship relationship) {
relationships.remove(relationship.carrier());
relationships.remove(relationship.carried());
}
private void remember(Relationship relationship) {
relationships.put(relationship.carrier(), relationship);
relationships.put(relationship.carried(), relationship);
}
public int close() {
closed = true;
int unresolved = 0;
for (Relationship relationship : java.util.Set.copyOf(relationships.values())) {
stop(relationship);
if (port.linked(relationship.carrier(), relationship.carried())) {
unresolved++;
}
}
relationships.clear();
mounting.clear();
return unresolved;
}
public boolean isCarrier(UUID player) {
Relationship relationship = relationships.get(player);
return relationship != null && relationship.carrier().equals(player);
}
public boolean involved(UUID participant) {
return relationships.containsKey(participant);
}
}
@@ -1,10 +1,33 @@
package games.dmg.purpurcarry;
import java.util.Objects;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class PurpurCarryPlugin extends JavaPlugin {
private CarryController controller;
@Override
public void onEnable() {
controller = new CarryController(
getServer(),
getLogger(),
task -> getServer().getScheduler().runTask(this, task)
);
getServer().getPluginManager().registerEvents(controller, this);
CarryCommand carry = new CarryCommand();
PluginCommand command = Objects.requireNonNull(
getCommand("carry"), "plugin.yml must declare /carry");
command.setExecutor(carry);
command.setTabCompleter(carry);
getLogger().info("Purpur Carry enabled.");
}
@Override
public void onDisable() {
if (controller != null) {
controller.close();
controller = null;
}
}
}
+9
View File
@@ -4,3 +4,12 @@ main: games.dmg.purpurcarry.PurpurCarryPlugin
api-version: "1.20"
description: Native animal and Villager carrying for SoMC.
author: dmg.games
commands:
carry:
description: Explain native animal and Villager carrying controls.
usage: /carry [help]
permission: purpurcarry.use
permissions:
purpurcarry.use:
description: Allows carrying eligible animals and Villagers with a Lead.
default: true
@@ -0,0 +1,285 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.entity.Animals;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.entity.EntityDismountEvent;
import org.bukkit.event.entity.EntityMountEvent;
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.util.Vector;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Actual Purpur 2618 listener discovery and event dispatch with entity boundaries doubled. */
class NativeCarryRoutingTest {
private static Server previousServer;
private PluginManager events;
private SimpleCommandMap commandMap;
private Plugin plugin;
private CarryController controller;
private final List<Runnable> scheduled = new ArrayList<>();
private final Player carrier = mock(Player.class);
private final Animals target = mock(Animals.class);
private boolean attached;
private int mountAttempts;
@BeforeAll
static void rememberServer() {
previousServer = Bukkit.getServer();
}
@AfterAll
static void restoreServer() throws Exception {
setServer(previousServer);
}
@BeforeEach
void registerProductionListener() throws Exception {
HandlerList.unregisterAll();
Server server = mock(Server.class);
when(server.isPrimaryThread()).thenReturn(true);
when(server.getLogger()).thenReturn(Logger.getLogger("NativeCarryRoutingTest"));
setServer(server);
commandMap = new SimpleCommandMap(server, new HashMap<>());
events = new PaperPluginManagerImpl(server, commandMap, null);
when(server.getPluginManager()).thenReturn(events);
plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("PurpurCarry");
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeCarryRoutingTest"));
PluginDescriptionFile description = new PluginDescriptionFile(
"PurpurCarry", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
UUID carrierId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
when(carrier.getUniqueId()).thenReturn(carrierId);
when(target.getUniqueId()).thenReturn(targetId);
when(server.getEntity(carrierId)).thenReturn(carrier);
when(server.getEntity(targetId)).thenReturn(target);
World world = mock(World.class);
when(carrier.getWorld()).thenReturn(world);
when(target.getWorld()).thenReturn(world);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack lead = mock(ItemStack.class);
when(lead.getType()).thenReturn(Material.LEAD);
when(carrier.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(lead);
when(carrier.hasPermission("purpurcarry.use")).thenReturn(true);
when(carrier.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(carrier.isSneaking()).thenReturn(true);
when(carrier.isOnline()).thenReturn(true);
when(carrier.isValid()).thenReturn(true);
when(target.isValid()).thenReturn(true);
when(carrier.getPassengers()).thenAnswer(invocation ->
attached ? List.of(target) : List.of());
when(target.getPassengers()).thenReturn(List.of());
when(target.getVehicle()).thenAnswer(invocation -> attached ? carrier : null);
when(carrier.addPassenger(target)).thenAnswer(invocation -> {
mountAttempts++;
EntityMountEvent event = new EntityMountEvent(target, carrier);
events.callEvent(event);
attached = !event.isCancelled();
return attached;
});
when(target.leaveVehicle()).thenAnswer(invocation -> {
EntityDismountEvent event = new EntityDismountEvent(target, carrier, true);
events.callEvent(event);
if (!event.isCancelled()) {
attached = false;
}
return true;
});
doAnswer(invocation -> null).when(carrier).sendMessage(any(String.class));
controller = new CarryController(server, server.getLogger(), scheduled::add);
events.registerEvents(controller, plugin);
}
@Test
void duplicateEntityAndAtEntityDispatchCreatesOnlyOneRelationship() {
PlayerInteractEntityEvent first = new PlayerInteractEntityEvent(
carrier, target, EquipmentSlot.HAND);
PlayerInteractAtEntityEvent duplicate = new PlayerInteractAtEntityEvent(
carrier, target, new Vector(), EquipmentSlot.HAND);
events.callEvent(first);
events.callEvent(duplicate);
assertTrue(first.isCancelled());
assertTrue(duplicate.isCancelled());
assertTrue(attached);
assertEquals(1, mountAttempts);
}
@Test
void actualRegisteredCommandExecutesAndCompletesThroughTheCommandMap() throws Exception {
var constructor = PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class);
constructor.setAccessible(true);
PluginCommand registered = constructor.newInstance("carry", plugin);
CarryCommand carry = new CarryCommand();
registered.setExecutor(carry);
registered.setTabCompleter(carry);
commandMap.register("purpurcarry", registered);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("purpurcarry.use")).thenReturn(true);
List<String> messages = new ArrayList<>();
doAnswer(invocation -> {
messages.add(invocation.getArgument(0, String.class));
return null;
}).when(sender).sendMessage(any(String.class));
assertTrue(commandMap.dispatch(sender, "carry"));
assertTrue(messages.getFirst().contains("one native passenger"));
assertTrue(commandMap.tabComplete(sender, "carry he").contains("help"));
assertFalse(commandMap.tabComplete(sender, "carry help").contains("help"));
}
@Test
void nativeDismountCancellationKeepsTrackingUntilAReleaseRetrySucceeds() {
events.callEvent(new PlayerInteractEntityEvent(carrier, target, EquipmentSlot.HAND));
assertTrue(attached);
Listener protection = new Listener() {};
events.registerEvent(EntityDismountEvent.class, protection, EventPriority.HIGHEST,
(listener, event) -> ((EntityDismountEvent) event).setCancelled(true), plugin);
PlayerInteractEvent refused = releaseEvent();
events.callEvent(refused);
assertFalse(refused.isCancelled());
assertTrue(attached);
HandlerList.unregisterAll(protection);
PlayerInteractEvent retried = releaseEvent();
events.callEvent(retried);
assertTrue(retried.isCancelled());
assertFalse(attached);
}
@Test
void nativeMountCancellationLeavesTheLeadInteractionAndEntityUnchanged() {
Listener protection = new Listener() {};
events.registerEvent(EntityMountEvent.class, protection, EventPriority.HIGHEST,
(listener, event) -> ((EntityMountEvent) event).setCancelled(true), plugin);
PlayerInteractEntityEvent start = new PlayerInteractEntityEvent(
carrier, target, EquipmentSlot.HAND);
events.callEvent(start);
assertFalse(start.isCancelled());
assertFalse(attached);
}
@Test
void registeredAtEntityInteractionUsesTheSameNativeCarryPath() {
PlayerInteractAtEntityEvent start = new PlayerInteractAtEntityEvent(
carrier, target, new Vector(), EquipmentSlot.HAND);
events.callEvent(start);
assertTrue(start.isCancelled());
assertTrue(attached);
}
@Test
void higherPriorityReleaseCancellationPreservesTheActiveRelationship() {
PlayerInteractEntityEvent start = new PlayerInteractEntityEvent(
carrier, target, EquipmentSlot.HAND);
events.callEvent(start);
assertTrue(attached);
Listener protection = new Listener() {};
events.registerEvent(PlayerInteractEvent.class, protection, EventPriority.HIGHEST,
(listener, event) -> ((PlayerInteractEvent) event).setCancelled(true), plugin);
PlayerInteractEvent release = new PlayerInteractEvent(
carrier, Action.RIGHT_CLICK_AIR,
carrier.getInventory().getItemInMainHand(), null, null, EquipmentSlot.HAND);
release.setCancelled(false);
events.callEvent(release);
assertTrue(release.isCancelled());
assertTrue(attached);
}
@Test
void higherPriorityCancellationRegisteredAfterCarryPreventsNativeMounting() {
Listener protection = new Listener() {};
events.registerEvent(PlayerInteractEntityEvent.class, protection, EventPriority.HIGHEST,
(listener, event) -> ((PlayerInteractEntityEvent) event).setCancelled(true), plugin);
PlayerInteractEntityEvent start = new PlayerInteractEntityEvent(
carrier, target, EquipmentSlot.HAND);
events.callEvent(start);
assertTrue(start.isCancelled());
assertFalse(attached);
}
@Test
void registeredInteractionMountsTheSameEntityAndLeadReleaseDismountsIt() {
PlayerInteractEntityEvent start = new PlayerInteractEntityEvent(
carrier, target, EquipmentSlot.HAND);
events.callEvent(start);
assertTrue(start.isCancelled());
assertTrue(attached);
assertSame(target, carrier.getPassengers().getFirst());
PlayerInteractEvent release = new PlayerInteractEvent(
carrier, Action.RIGHT_CLICK_AIR,
carrier.getInventory().getItemInMainHand(), null, null, EquipmentSlot.HAND);
release.setCancelled(false);
events.callEvent(release);
assertTrue(release.isCancelled());
assertFalse(attached);
}
private PlayerInteractEvent releaseEvent() {
PlayerInteractEvent release = new PlayerInteractEvent(
carrier, Action.RIGHT_CLICK_AIR,
carrier.getInventory().getItemInMainHand(), null, null, EquipmentSlot.HAND);
release.setCancelled(false);
return release;
}
private static void setServer(Server server) throws Exception {
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
}
@@ -0,0 +1,67 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class CarryCommandTest {
private final CarryCommand command = new CarryCommand();
@Test
void playersAndConsoleReceiveIdenticalHelpWhileExtraArgumentsReturnUsage() {
Sender player = sender(true, true);
Sender console = sender(false, true);
Command registered = mock(Command.class);
assertTrue(command.onCommand(player.sender, registered, "carry", new String[0]));
assertTrue(command.onCommand(console.sender, registered, "carry", new String[] {"help"}));
assertEquals(player.messages, console.messages);
assertTrue(player.messages.getFirst().contains("Lead"));
assertTrue(player.messages.getFirst().contains("one native passenger"));
assertTrue(player.messages.getFirst().contains("hazards"));
assertTrue(command.onCommand(player.sender, registered, "carry", new String[] {"extra"}));
assertEquals("Usage: /carry [help]", player.messages.getLast());
}
@Test
void autocompleteIsPermissionAwarePrefixFilteredAndMutationFree() {
Command registered = mock(Command.class);
CommandSender allowed = sender(true, true).sender;
CommandSender denied = sender(true, false).sender;
assertEquals(List.of("help"), command.onTabComplete(
allowed, registered, "carry", new String[] {""}));
assertEquals(List.of("help"), command.onTabComplete(
allowed, registered, "carry", new String[] {"he"}));
assertEquals(List.of(), command.onTabComplete(
allowed, registered, "carry", new String[] {"help"}));
assertEquals(List.of(), command.onTabComplete(
allowed, registered, "carry", new String[] {"help", "extra"}));
assertEquals(List.of(), command.onTabComplete(
denied, registered, "carry", new String[] {""}));
}
private static Sender sender(boolean player, boolean permitted) {
CommandSender sender = player ? mock(Player.class) : mock(CommandSender.class);
when(sender.hasPermission("purpurcarry.use")).thenReturn(permitted);
List<String> messages = new ArrayList<>();
doAnswer(invocation -> {
messages.add(invocation.getArgument(0, String.class));
return null;
}).when(sender).sendMessage(anyString());
return new Sender(sender, messages);
}
private record Sender(CommandSender sender, List<String> messages) {}
}
@@ -0,0 +1,425 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.AnimalTamer;
import org.bukkit.entity.Animals;
import org.bukkit.entity.Boss;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Monster;
import org.bukkit.entity.Player;
import org.bukkit.entity.Tameable;
import org.bukkit.entity.Villager;
import org.bukkit.entity.WanderingTrader;
import org.bukkit.event.block.Action;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityDismountEvent;
import org.bukkit.event.entity.EntityMountEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class CarryControllerTest {
@Test
void activeCarrierMayReleaseAfterPermissionIsRemoved() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
when(fixture.carrier.hasPermission("purpurcarry.use")).thenReturn(false);
PlayerInteractEvent release = fixture.releaseEvent();
fixture.controller.onLeadInteract(release);
assertTrue(release.isCancelled());
assertFalse(fixture.attached);
}
@Test
void repeatedAndDuplicateHandInteractionsCannotCreateNestedRelationships() {
Fixture fixture = new Fixture();
PlayerInteractEntityEvent first = fixture.startEvent();
fixture.controller.onEntityInteract(first);
PlayerInteractEntityEvent repeated = fixture.startEvent();
fixture.controller.onEntityInteract(repeated);
PlayerInteractEntityEvent offhand = new PlayerInteractEntityEvent(
fixture.carrier, fixture.target, EquipmentSlot.OFF_HAND);
fixture.controller.onEntityInteract(offhand);
assertTrue(first.isCancelled());
assertTrue(repeated.isCancelled());
assertFalse(offhand.isCancelled());
assertEquals(1, fixture.mounts);
assertEquals(1, fixture.carrier.getPassengers().size());
}
@Test
void deniedOffhandCancelledAndUnrelatedLeadInteractionsRemainUntouched() {
Fixture denied = new Fixture();
when(denied.carrier.hasPermission("purpurcarry.use")).thenReturn(false);
PlayerInteractEntityEvent deniedEvent = denied.startEvent();
denied.controller.onEntityInteract(deniedEvent);
assertFalse(deniedEvent.isCancelled());
Fixture creative = new Fixture();
when(creative.carrier.getGameMode()).thenReturn(GameMode.CREATIVE);
PlayerInteractEntityEvent creativeEvent = creative.startEvent();
creative.controller.onEntityInteract(creativeEvent);
assertFalse(creativeEvent.isCancelled());
Fixture offhand = new Fixture();
PlayerInteractEntityEvent offhandEvent = new PlayerInteractEntityEvent(
offhand.carrier, offhand.target, EquipmentSlot.OFF_HAND);
offhand.controller.onEntityInteract(offhandEvent);
assertFalse(offhandEvent.isCancelled());
Fixture cancelled = new Fixture();
PlayerInteractEntityEvent cancelledEvent = cancelled.startEvent();
cancelledEvent.setCancelled(true);
cancelled.controller.onEntityInteract(cancelledEvent);
assertFalse(cancelled.attached);
Fixture unrelated = new Fixture();
when(unrelated.lead.getType()).thenReturn(Material.STICK);
PlayerInteractEntityEvent unrelatedEvent = unrelated.startEvent();
unrelated.controller.onEntityInteract(unrelatedEvent);
assertFalse(unrelatedEvent.isCancelled());
assertFalse(unrelated.attached);
}
@Test
void adapterAdmitsNamedBabyAnimalsVillagersAndOnlyAnOwnedTameableOwner() {
Animals namedBaby = mock(Animals.class);
when(namedBaby.getCustomName()).thenReturn("Bessie");
when(namedBaby.isAdult()).thenReturn(false);
Fixture animal = new Fixture(namedBaby);
PlayerInteractEntityEvent animalStart = animal.startEvent();
animal.controller.onEntityInteract(animalStart);
assertTrue(animalStart.isCancelled());
assertTrue(animal.attached);
Fixture villager = new Fixture(mock(Villager.class));
PlayerInteractEntityEvent villagerStart = villager.startEvent();
villager.controller.onEntityInteract(villagerStart);
assertTrue(villagerStart.isCancelled());
Animals tameableAnimal = mock(
Animals.class, withSettings().extraInterfaces(Tameable.class));
AnimalTamer owner = mock(AnimalTamer.class);
when(((Tameable) tameableAnimal).getOwner()).thenReturn(owner);
Fixture owned = new Fixture(tameableAnimal);
when(owner.getUniqueId()).thenReturn(owned.carrierId);
PlayerInteractEntityEvent ownedStart = owned.startEvent();
owned.controller.onEntityInteract(ownedStart);
assertTrue(ownedStart.isCancelled());
Animals otherTameable = mock(
Animals.class, withSettings().extraInterfaces(Tameable.class));
AnimalTamer otherOwner = mock(AnimalTamer.class);
when(otherOwner.getUniqueId()).thenReturn(UUID.randomUUID());
when(((Tameable) otherTameable).getOwner()).thenReturn(otherOwner);
Fixture denied = new Fixture(otherTameable);
PlayerInteractEntityEvent deniedStart = denied.startEvent();
denied.controller.onEntityInteract(deniedStart);
assertFalse(deniedStart.isCancelled());
assertFalse(denied.attached);
}
@Test
void adapterRejectsHostilesBossesTradersLeashesAndExistingRelationships() {
for (Entity target : List.of(
mock(Player.class), mock(Monster.class),
mock(Boss.class), mock(WanderingTrader.class))) {
Fixture fixture = new Fixture(target);
PlayerInteractEntityEvent event = fixture.startEvent();
fixture.controller.onEntityInteract(event);
assertFalse(event.isCancelled());
assertFalse(fixture.attached);
}
Fixture leashed = new Fixture();
when(((Mob) leashed.target).isLeashed()).thenReturn(true);
PlayerInteractEntityEvent leashedEvent = leashed.startEvent();
leashed.controller.onEntityInteract(leashedEvent);
assertFalse(leashedEvent.isCancelled());
Fixture riding = new Fixture();
when(riding.target.isInsideVehicle()).thenReturn(true);
PlayerInteractEntityEvent ridingEvent = riding.startEvent();
riding.controller.onEntityInteract(ridingEvent);
assertFalse(ridingEvent.isCancelled());
Fixture passenger = new Fixture();
when(passenger.target.getPassengers()).thenReturn(List.of(mock(Entity.class)));
PlayerInteractEntityEvent passengerEvent = passenger.startEvent();
passenger.controller.onEntityInteract(passengerEvent);
assertFalse(passengerEvent.isCancelled());
}
@Test
void cleanupFindsTheOwnedPassengerWhenDirectEntityLookupIsUnavailable() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
when(fixture.server.getEntity(fixture.targetId)).thenReturn(null);
fixture.controller.close();
assertFalse(fixture.attached);
}
@Test
void disableAttemptsNativeCleanupAndClearsQueuedState() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
fixture.controller.close();
assertFalse(fixture.attached);
PlayerInteractEntityEvent afterDisable = fixture.startEvent();
fixture.controller.onEntityInteract(afterDisable);
assertFalse(afterDisable.isCancelled());
assertFalse(fixture.attached);
}
@Test
void disconnectDeathAndRemovalEachCleanUpOnlyTheOwnedRelationship() {
Fixture disconnected = new Fixture();
disconnected.controller.onEntityInteract(disconnected.startEvent());
PlayerQuitEvent quit = mock(PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(disconnected.carrier);
disconnected.controller.onQuit(quit);
assertFalse(disconnected.attached);
Fixture died = new Fixture();
died.controller.onEntityInteract(died.startEvent());
EntityDeathEvent death = mock(EntityDeathEvent.class);
when(death.getEntity()).thenReturn((LivingEntity) died.target);
died.controller.onDeath(death);
assertFalse(died.attached);
Fixture removed = new Fixture();
removed.controller.onEntityInteract(removed.startEvent());
EntityRemoveEvent removal = mock(EntityRemoveEvent.class);
when(removal.getEntity()).thenReturn(removed.target);
removed.controller.onRemove(removal);
assertFalse(removed.attached);
}
@Test
void acceptedNativeDismountReconcilesTrackingWithoutASecondDismount() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
EntityDismountEvent event = new EntityDismountEvent(
fixture.target, fixture.carrier, true);
fixture.controller.onEntityDismount(event);
fixture.attached = false;
fixture.drain();
PlayerInteractEntityEvent fresh = fixture.startEvent();
fixture.controller.onEntityInteract(fresh);
assertFalse(event.isCancelled());
assertTrue(fresh.isCancelled());
assertTrue(fixture.attached);
}
@Test
void activeParticipantsCannotAcquireNestedVehiclesOrPassengers() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
org.bukkit.entity.Entity vehicle = mock(org.bukkit.entity.Entity.class);
org.bukkit.entity.Entity extra = mock(org.bukkit.entity.Entity.class);
EntityMountEvent carrierVehicle = new EntityMountEvent(fixture.carrier, vehicle);
EntityMountEvent extraPassenger = new EntityMountEvent(extra, fixture.carrier);
fixture.controller.onEntityMount(carrierVehicle);
fixture.controller.onEntityMount(extraPassenger);
assertTrue(carrierVehicle.isCancelled());
assertTrue(extraPassenger.isCancelled());
assertTrue(fixture.attached);
}
@Test
void carriedEntityTeleportCleansUpBeforeTheNativeTransition() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
EntityTeleportEvent event = mock(EntityTeleportEvent.class);
when(event.getEntity()).thenReturn(fixture.target);
fixture.controller.onEntityTeleport(event);
assertFalse(fixture.attached);
verify(event, never()).setCancelled(true);
}
@Test
void carrierVehicleEntryCleansUpBeforeTheNativeTransition() {
Fixture fixture = new Fixture();
fixture.controller.onEntityInteract(fixture.startEvent());
VehicleEnterEvent event = mock(VehicleEnterEvent.class);
when(event.getEntered()).thenReturn(fixture.carrier);
fixture.controller.onVehicleEnter(event);
assertFalse(fixture.attached);
verify(event, never()).setCancelled(true);
}
@Test
void acceptedTeleportCleansUpButRefusedNativeDismountCancelsHonestly() {
Fixture accepted = new Fixture();
accepted.controller.onEntityInteract(accepted.startEvent());
PlayerTeleportEvent acceptedTeleport = mock(PlayerTeleportEvent.class);
when(acceptedTeleport.getPlayer()).thenReturn(accepted.carrier);
accepted.controller.onPlayerTeleport(acceptedTeleport);
assertFalse(accepted.attached);
verify(acceptedTeleport, never()).setCancelled(true);
Fixture refused = new Fixture();
refused.controller.onEntityInteract(refused.startEvent());
refused.dismountAllowed = false;
PlayerTeleportEvent refusedTeleport = mock(PlayerTeleportEvent.class);
when(refusedTeleport.getPlayer()).thenReturn(refused.carrier);
refused.controller.onPlayerTeleport(refusedTeleport);
assertTrue(refused.attached);
verify(refusedTeleport).setCancelled(true);
assertTrue(refused.messages.getLast().contains("refused"));
}
@Test
void registeredInteractionUsesTheSameLiveEntityForNativeMountAndPlayerRelease() {
Fixture fixture = new Fixture();
PlayerInteractEntityEvent start = fixture.startEvent();
fixture.controller.onEntityInteract(start);
assertTrue(start.isCancelled());
assertTrue(fixture.attached);
assertSame(fixture.target, fixture.carrier.getPassengers().getFirst());
PlayerInteractEvent release = fixture.releaseEvent();
fixture.controller.onLeadInteract(release);
assertTrue(release.isCancelled());
assertFalse(fixture.attached);
assertTrue(fixture.carrier.getPassengers().isEmpty());
verify(fixture.target, never()).remove();
verify(fixture.target, never()).teleport(any(Location.class));
verify(fixture.target, never()).setVelocity(any());
verify(fixture.target, never()).setFallDistance(org.mockito.ArgumentMatchers.anyFloat());
verify(fixture.target, never()).setInvulnerable(org.mockito.ArgumentMatchers.anyBoolean());
verify(fixture.carrier, never()).eject();
verify(fixture.carrier, never()).removePassenger(any());
verify(fixture.lead, never()).setAmount(anyInt());
verify(fixture.inventory, never()).setItemInMainHand(any());
}
private static final class Fixture {
private final Server server = mock(Server.class);
private final Player carrier = mock(Player.class);
private final Entity target;
private final ItemStack lead = mock(ItemStack.class);
private final PlayerInventory inventory = mock(PlayerInventory.class);
private final UUID carrierId = UUID.randomUUID();
private final UUID targetId = UUID.randomUUID();
private final List<String> messages = new ArrayList<>();
private final List<Runnable> scheduled = new ArrayList<>();
private boolean attached;
private boolean mountAllowed = true;
private int mounts;
private boolean dismountAllowed = true;
private final CarryController controller;
private Fixture() {
this(mock(Animals.class));
}
private Fixture(Entity target) {
this.target = target;
when(carrier.getUniqueId()).thenReturn(carrierId);
when(target.getUniqueId()).thenReturn(targetId);
when(server.getEntity(carrierId)).thenReturn(carrier);
when(server.getEntity(targetId)).thenReturn(target);
when(carrier.hasPermission("purpurcarry.use")).thenReturn(true);
when(carrier.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(lead);
when(carrier.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(carrier.isSneaking()).thenReturn(true);
when(carrier.isOnline()).thenReturn(true);
when(carrier.isValid()).thenReturn(true);
when(target.isValid()).thenReturn(true);
when(carrier.getPassengers()).thenAnswer(invocation ->
attached ? List.of(target) : List.of());
when(target.getPassengers()).thenReturn(List.of());
when(target.getVehicle()).thenAnswer(invocation -> attached ? carrier : null);
when(carrier.addPassenger(target)).thenAnswer(invocation -> {
mounts++;
if (mountAllowed) {
attached = true;
}
return mountAllowed;
});
when(target.leaveVehicle()).thenAnswer(invocation -> {
if (dismountAllowed) {
attached = false;
}
return dismountAllowed;
});
when(lead.getType()).thenReturn(Material.LEAD);
doAnswer(invocation -> {
messages.add(invocation.getArgument(0, String.class));
return null;
}).when(carrier).sendMessage(anyString());
controller = new CarryController(
server, Logger.getAnonymousLogger(), scheduled::add);
}
private PlayerInteractEntityEvent startEvent() {
return new PlayerInteractEntityEvent(carrier, target, EquipmentSlot.HAND);
}
private void drain() {
List<Runnable> tasks = List.copyOf(scheduled);
scheduled.clear();
tasks.forEach(Runnable::run);
}
private PlayerInteractEvent releaseEvent() {
PlayerInteractEvent event = new PlayerInteractEvent(
carrier, Action.RIGHT_CLICK_AIR, lead, null, null, EquipmentSlot.HAND);
event.setCancelled(false);
return event;
}
}
}
@@ -0,0 +1,51 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class CarryEligibilityTest {
private final UUID requester = UUID.randomUUID();
@Test
void permitsLiveFreeAnimalsAndVillagersIncludingNamedBabies() {
assertTrue(CarryEligibility.eligible(requester, target(CarryEligibility.Kind.ANIMAL)));
assertTrue(CarryEligibility.eligible(requester, target(CarryEligibility.Kind.VILLAGER)));
}
@Test
void excludesUnsafeOrAlreadyAttachedTargets() {
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.OTHER)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.ANIMAL).withAlive(false)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.ANIMAL).withHostile(true)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.ANIMAL).withBoss(true)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.VILLAGER).withWanderingTrader(true)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.ANIMAL).withRiding(true)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.ANIMAL).withPassengers(true)));
assertFalse(CarryEligibility.eligible(requester,
target(CarryEligibility.Kind.ANIMAL).withLeashed(true)));
}
@Test
void onlyTheOwnerMayCarryAnOwnedTameable() {
UUID owner = UUID.randomUUID();
CarryEligibility.Target tameable = target(CarryEligibility.Kind.ANIMAL).withOwner(owner);
assertTrue(CarryEligibility.eligible(owner, tameable));
assertFalse(CarryEligibility.eligible(requester, tameable));
}
private static CarryEligibility.Target target(CarryEligibility.Kind kind) {
return new CarryEligibility.Target(kind, true, false, false, false,
false, false, false, null);
}
}
@@ -0,0 +1,164 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class CarryServiceTest {
private final UUID carrier = UUID.randomUUID();
private final UUID carried = UUID.randomUUID();
@Test
void closeAttemptsNativeCleanupReportsRefusalAndClearsAdmissionState() {
FakePort port = new FakePort();
CarryService service = new CarryService(port);
assertEquals(CarryService.Start.STARTED, service.start(carrier, carried));
port.dismountAllowed = false;
assertEquals(1, service.close());
assertFalse(service.involved(carrier));
assertFalse(service.involved(carried));
assertEquals(CarryService.Start.REFUSED,
service.start(UUID.randomUUID(), UUID.randomUUID()));
}
@Test
void nativeDetachmentClearsTrackingWithoutForcingAnotherDismount() {
FakePort port = new FakePort();
CarryService service = new CarryService(port);
assertEquals(CarryService.Start.STARTED, service.start(carrier, carried));
port.linked = false;
service.reconcile(carried);
assertFalse(service.involved(carrier));
assertFalse(service.involved(carried));
assertEquals(0, port.dismounts);
}
@Test
void reservationsBlockReentrantAndNestedMountAttempts() {
UUID other = UUID.randomUUID();
CarryService[] holder = new CarryService[1];
CarryService.Port port = new CarryService.Port() {
private boolean linked;
@Override
public boolean mount(UUID mountedCarrier, UUID mountedTarget) {
assertEquals(CarryService.Start.BUSY, holder[0].start(other, mountedTarget));
assertEquals(CarryService.Start.BUSY, holder[0].start(mountedCarrier, other));
linked = true;
return true;
}
@Override public boolean attached(UUID first, UUID second) { return linked; }
@Override public boolean linked(UUID first, UUID second) { return linked; }
@Override public boolean dismount(UUID first, UUID second) { linked = false; return true; }
};
holder[0] = new CarryService(port);
assertEquals(CarryService.Start.STARTED, holder[0].start(carrier, carried));
assertEquals(CarryService.Start.BUSY, holder[0].start(other, carried));
assertEquals(CarryService.Start.BUSY, holder[0].start(carrier, other));
}
@Test
void refusedDismountKeepsTheRelationshipForAnHonestRetry() {
FakePort port = new FakePort();
CarryService service = new CarryService(port);
assertEquals(CarryService.Start.STARTED, service.start(carrier, carried));
port.dismountAllowed = false;
assertFalse(service.cleanup(carried));
assertTrue(service.involved(carrier));
assertTrue(service.involved(carried));
port.dismountAllowed = true;
assertTrue(service.cleanup(carried));
assertFalse(service.involved(carrier));
}
@Test
void lifecycleCleanupMayBeRequestedForEitherParticipant() {
FakePort port = new FakePort();
CarryService service = new CarryService(port);
assertEquals(CarryService.Start.STARTED, service.start(carrier, carried));
assertTrue(service.cleanup(carried));
assertFalse(service.involved(carrier));
assertFalse(service.involved(carried));
assertFalse(port.linked);
}
@Test
void invalidNativeAttachmentIsDismountedInsteadOfLeftUntracked() {
FakePort port = new FakePort();
port.validAttachment = false;
CarryService service = new CarryService(port);
assertEquals(CarryService.Start.REFUSED, service.start(carrier, carried));
assertFalse(port.linked);
assertEquals(1, port.dismounts);
assertFalse(service.involved(carrier));
assertFalse(service.involved(carried));
}
@Test
void startsAndReleasesOneNativeRelationship() {
FakePort port = new FakePort();
CarryService service = new CarryService(port);
assertEquals(CarryService.Start.STARTED, service.start(carrier, carried));
assertTrue(service.isCarrier(carrier));
assertTrue(service.involved(carried));
assertEquals(carrier, port.carrier);
assertEquals(carried, port.carried);
assertEquals(CarryService.Stop.STOPPED, service.stopCarrier(carrier));
assertFalse(service.involved(carrier));
assertFalse(service.involved(carried));
assertFalse(port.linked);
}
private final class FakePort implements CarryService.Port {
private UUID carrier;
private UUID carried;
private boolean linked;
private boolean validAttachment = true;
private boolean dismountAllowed = true;
private int dismounts;
@Override
public boolean mount(UUID carrier, UUID carried) {
this.carrier = carrier;
this.carried = carried;
linked = true;
return true;
}
@Override
public boolean attached(UUID carrier, UUID carried) {
return validAttachment && linked && this.carrier.equals(carrier)
&& this.carried.equals(carried);
}
@Override
public boolean linked(UUID carrier, UUID carried) {
return linked && this.carrier.equals(carrier) && this.carried.equals(carried);
}
@Override
public boolean dismount(UUID carrier, UUID carried) {
dismounts++;
if (dismountAllowed) {
linked = false;
}
return dismountAllowed;
}
}
}
@@ -0,0 +1,18 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
final class PluginWiringTest {
@Test
void entrypointOwnsTheRegisteredControllerAndDisableCleanup() throws Exception {
Field controller = PurpurCarryPlugin.class.getDeclaredField("controller");
Method disable = PurpurCarryPlugin.class.getDeclaredMethod("onDisable");
assertEquals(CarryController.class, controller.getType());
assertEquals(void.class, disable.getReturnType());
}
}
@@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
@@ -27,12 +28,21 @@ final class PurpurCarryDistributionTest {
assertEquals(version, metadata.get("version"));
assertEquals("games.dmg.purpurcarry.PurpurCarryPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"));
assertFalse(metadata.containsKey("commands"));
assertFalse(metadata.containsKey("permissions"));
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
Map<?, ?> carry = (Map<?, ?>) commands.get("carry");
assertEquals("/carry [help]", carry.get("usage"));
assertEquals("purpurcarry.use", carry.get("permission"));
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
Map<?, ?> use = (Map<?, ?>) permissions.get("purpurcarry.use");
assertEquals(Boolean.TRUE, use.get("default"));
Class<?> entrypoint = Class.forName((String) metadata.get("main"));
assertTrue(JavaPlugin.class.isAssignableFrom(entrypoint));
var bytecode = jar.getJarEntry("games/dmg/purpurcarry/PurpurCarryPlugin.class");
assertNotNull(bytecode);
assertNotNull(jar.getJarEntry("games/dmg/purpurcarry/CarryController.class"));
assertNotNull(jar.getJarEntry("games/dmg/purpurcarry/CarryService.class"));
assertNotNull(jar.getJarEntry("games/dmg/purpurcarry/CarryEligibility.class"));
assertNotNull(jar.getJarEntry("games/dmg/purpurcarry/CarryCommand.class"));
try (var input = new DataInputStream(jar.getInputStream(bytecode))) {
assertEquals(0xCAFEBABE, input.readInt());
input.readUnsignedShort();
@@ -44,4 +54,17 @@ final class PurpurCarryDistributionTest {
|| entry.getName().endsWith("Test.class")));
}
}
@Test
void documentsAndPinsTheDeclaredNativeVerificationBoundary() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
String readme = Files.readString(Path.of("README.md"));
assertTrue(build.contains("nativeCarryTest"));
assertTrue(build.contains("tasks.check { dependsOn(nativeCarryTest) }"));
assertTrue(build.contains(
"4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
assertTrue(readme.contains("checksum-pinned Purpur event/command suite"));
assertTrue(readme.contains("patch-only mode"));
}
}