Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f9c817cf |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user