1 Commits
Author SHA1 Message Date
dmg ff0523860c feat(heights): carry consenting players with native dismounts
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 54s
2026-09-10 23:48:51 -04:00
11 changed files with 929 additions and 6 deletions
+22
View File
@@ -57,6 +57,28 @@ Heights tracks only its loaded clouds and checks them every five ticks, dispatch
**Verification:** automated domain and Bukkit-boundary tests cover this implementation. Actual brewing, cloud delivery, client appearance, and logout/restart gameplay on Purpur 26.2 build 2618 still require manual acceptance checks.
## Consensual carrying (US-010; live acceptance pending)
Sneak-right-click another player with your main hand to request a ride or pickup. The larger player is always the carrier, regardless of who asks. The other player has **30 seconds** to explicitly accept:
```text
/heights carry accept
/heights carry decline
/heights carry stop
```
These commands require **no administrator permission**; settings and player-height commands still require `spigotheights.admin`. Console cannot carry players. One outstanding request per participant prevents replacement/ambiguous consent. Either participant can withdraw a pending request with `stop`; `decline` rejects an incoming request. Requests and carry tracking are not persisted.
Both players must be available and within **4 blocks**, in the same world, when requesting and accepting. Their **effective** scale difference must be at least **three current adjustment steps** (normally `0.3`), including other plugins' attribute modifiers. Neither may already have a vehicle or passengers. Heights permits only one rider per carrier, and blocks additional/nested mounts involving an active pair.
Either player can stop with **normal Minecraft dismounting**. Heights calls the rider's native `leaveVehicle()` and allows ordinary sneak-dismounts, leaving placement, falling, damage, and environmental hazards to Minecraft. There is no safe-ground requirement or search, rescue teleport, saved-position/spawn fallback, motion/fall-distance reset, invulnerability, or recovery system. Getting off above a drop is a gameplay risk.
Eligibility is rechecked every five ticks, including temporary potion application/expiry, administrator edits and external scale modifiers. Death, disconnect, plugin disable and loss of eligibility attempt native cleanup. For observed teleport events, Heights detaches the pair and lets the original teleport continue, without replaying or redirecting it. Native carrier teleports that refuse passengers before emitting an event may still require `/heights carry stop` first.
Server/plugin cancellation remains authoritative: a refused dismount leaves accurate tracking and is reported, not bypassed or called successful. A cancelled voluntary stop can be retried by either player; automatic eligibility cleanup continues checking and reports refusal once per ride rather than spamming. Lifecycle/disable refusals are logged. Native dismount events are reconciled after dispatch so a later cancellation is honored. An unrelated vehicle is never ejected during stale-pair cleanup.
**Verification:** automated domain and constructed Bukkit-event tests are not live-server evidence. Purpur 26.2 build 2618 still needs checks for scaled rider positioning and movement, ordinary dismount placement/falling, cancellation interoperability, both-role death/disconnect, portal/cross-world teleports, plugin disable and eligibility changes. No throwing or added launch force, player items, storage, or changes to throwable-potion opt-in behavior are included.
## Tiny-player launchers
A player below the configured scale threshold can walk onto a hopper whose output points into a dispenser. If the block in front of that dispenser is passable, the player is moved there and launched in the direction the dispenser faces. The launcher does not require redstone.
@@ -0,0 +1,176 @@
package games.dmg.spigotheights;
import java.util.UUID;
import java.util.function.*;
import java.util.logging.Logger;
import org.bukkit.GameMode;
import org.bukkit.Server;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerInteractAtEntityEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
/** Bukkit boundary for the consent state machine. All methods run on the server thread. */
public final class BukkitCarry implements Listener, CarryService.Port, AutoCloseable {
private final Function<UUID, Player> players;
private final Consumer<Runnable> nextTick;
private final Logger logger;
private final ToDoubleFunction<Player> scales;
private final CarryService service;
private final java.util.Set<UUID> dismounting = new java.util.HashSet<>();
private final java.util.Set<UUID> queuedStops = new java.util.HashSet<>();
private UUID mountingCarrier, mountingRider;
private boolean closed;
public BukkitCarry(Server server, Supplier<HeightSettings> settings, Consumer<Runnable> nextTick, Logger logger) {
this(server::getPlayer, settings, nextTick, logger, player -> {
AttributeInstance attribute = player.getAttribute(Attribute.SCALE);
return attribute == null ? Double.NaN : attribute.getValue();
}, () -> System.nanoTime() / 1_000_000);
}
BukkitCarry(Function<UUID, Player> players, Supplier<HeightSettings> settings,
Consumer<Runnable> nextTick, Logger logger, ToDoubleFunction<Player> scales, LongSupplier clock) {
this.players = players; this.nextTick = nextTick; this.logger = logger;
this.scales = scales;
service = new CarryService(this, () -> settings.get().adjustmentStep(), clock);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void interact(PlayerInteractEntityEvent event) {
if (event.isCancelled() || event instanceof PlayerInteractAtEntityEvent
|| event.getHand() != EquipmentSlot.HAND || !event.getPlayer().isSneaking()
|| !(event.getRightClicked() instanceof Player target)) { return; }
event.setCancelled(true);
Player sender = event.getPlayer();
String result = service.request(sender.getUniqueId(), target.getUniqueId());
sender.sendMessage(result);
if (result.equals("Carry request sent.")) {
target.sendMessage(sender.getName() + (scales.applyAsDouble(sender) > scales.applyAsDouble(target)
? " wants to carry you." : " wants to ride with you."));
}
}
public void command(Player player, String action) {
UUID id = player.getUniqueId();
player.sendMessage(switch (action) {
case "accept" -> service.accept(id);
case "decline" -> service.decline(id);
case "stop" -> service.stop(id);
default -> "Usage: /heights carry accept|decline|stop";
});
}
@Override public CarryService.State state(UUID id) {
Player player = players.apply(id);
if (player == null) { return null; }
return new CarryService.State(scales.applyAsDouble(player),
player.isOnline() && player.isValid() && !player.isDead() && !player.isSleeping()
&& player.getGameMode() != GameMode.SPECTATOR,
!player.isInsideVehicle() && player.getPassengers().isEmpty());
}
@Override public boolean nearby(UUID first, UUID second) {
Player a = players.apply(first), b = players.apply(second);
return a != null && b != null && a.getWorld().getUID().equals(b.getWorld().getUID())
&& a.getLocation().toVector().distanceSquared(b.getLocation().toVector()) <= 16;
}
@Override public boolean mount(UUID carrier, UUID rider) {
Player a = players.apply(carrier), b = players.apply(rider);
if (a == null || b == null) { return false; }
mountingCarrier = carrier; mountingRider = rider;
try { return a.addPassenger(b) && attached(carrier, rider); }
finally { mountingCarrier = null; mountingRider = null; }
}
@Override public boolean linked(UUID carrier, UUID rider) {
Player b = players.apply(rider);
return b != null && b.getVehicle() != null && b.getVehicle().getUniqueId().equals(carrier);
}
@Override public boolean attached(UUID carrier, UUID rider) {
Player a = players.apply(carrier), b = players.apply(rider);
return a != null && b != null && b.getVehicle() != null && b.getVehicle().getUniqueId().equals(carrier)
&& !a.isInsideVehicle() && b.getPassengers().isEmpty() && a.getPassengers().size() == 1
&& a.getPassengers().getFirst().getUniqueId().equals(rider)
&& a.getWorld().getUID().equals(b.getWorld().getUID());
}
@Override public boolean dismount(UUID carrier, UUID rider) {
Player b = players.apply(rider);
if (b == null) {
Player a = players.apply(carrier);
if (a != null) {
b = a.getPassengers().stream().filter(entity -> entity.getUniqueId().equals(rider))
.filter(Player.class::isInstance).map(Player.class::cast).findFirst().orElse(null);
}
}
if (b == null || b.getVehicle() == null || !b.getVehicle().getUniqueId().equals(carrier)) {
return true; // Already detached; never disturb an unrelated vehicle.
}
if (!dismounting.add(rider)) { return false; }
try {
b.leaveVehicle();
} finally {
dismounting.remove(rider);
}
// Native API return values do not reliably reflect cancellation. Inspect the relationship.
return b.getVehicle() == null || !b.getVehicle().getUniqueId().equals(carrier);
}
@Override public void message(UUID id, String message) {
Player player = players.apply(id);
if (player != null && player.isOnline()) { player.sendMessage(message); }
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void mountEvent(org.bukkit.event.entity.EntityMountEvent event) {
UUID rider = event.getEntity().getUniqueId(), carrier = event.getMount().getUniqueId();
if (rider.equals(mountingRider) && carrier.equals(mountingCarrier)) { return; }
if (service.involved(rider) || service.involved(carrier)
|| rider.equals(mountingCarrier) || carrier.equals(mountingRider)
|| rider.equals(mountingRider) || carrier.equals(mountingCarrier)) { event.setCancelled(true); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void dismountEvent(org.bukkit.event.entity.EntityDismountEvent event) {
UUID id = event.getEntity().getUniqueId();
if (event.isCancelled() || dismounting.contains(id) || !service.involved(id)
|| closed || !queuedStops.add(id)) { return; }
// The event precedes native detachment. Reconcile actual attachment next tick;
// never cancel sneak-dismount or force a second dismount if another listener cancels.
nextTick.accept(() -> {
queuedStops.remove(id);
if (!closed && !event.isCancelled()) { service.tick(); }
});
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void teleportEvent(org.bukkit.event.player.PlayerTeleportEvent event) {
if (event.isCancelled()) { return; }
Player player = event.getPlayer();
UUID id = player.getUniqueId();
// No rescue/replay teleport: detach through the native API, then allow the original.
// If another plugin refuses detachment, do not move an attached pair behind its back.
if (!service.cleanup(id)) {
event.setCancelled(true);
player.sendMessage("The server refused the dismount; stop carrying before teleporting.");
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void quit(org.bukkit.event.player.PlayerQuitEvent event) {
UUID id = event.getPlayer().getUniqueId();
if (!service.cleanup(id)) { reportUnresolved(id, "disconnect"); }
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void death(org.bukkit.event.entity.PlayerDeathEvent event) {
if (event.isCancelled()) { return; }
UUID id = event.getEntity().getUniqueId();
if (!service.cleanup(id)) { reportUnresolved(id, "death"); }
}
private void reportUnresolved(UUID id, String cause) {
logger.severe("Heights " + cause + ": carry involving " + id
+ " remains attached because the server refused native dismounting; no cancellation was bypassed.");
}
public void tick() { if (!closed) { service.tick(); } }
@Override public void close() {
closed = true;
int unresolved = service.close();
if (unresolved != 0) { logger.severe("Heights disable: " + unresolved
+ " native dismount(s) were refused by the server; no cancellation was bypassed."); }
}
}
@@ -0,0 +1,16 @@
package games.dmg.spigotheights;
/** Server-independent carry eligibility, using effective rather than base scale. */
public final class CarryPolicy {
private CarryPolicy() {}
public static boolean eligible(double carrier, double rider, double step) {
if (!Double.isFinite(carrier) || !Double.isFinite(rider) || !Double.isFinite(step)
|| carrier <= 0 || rider <= 0 || step <= 0 || carrier <= rider) {
return false;
}
double required = 3 * step;
double tolerance = 4 * Math.ulp(Math.max(carrier, rider));
return Double.isFinite(required) && carrier - rider + tolerance >= required;
}
}
@@ -0,0 +1,146 @@
package games.dmg.spigotheights;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.DoubleSupplier;
import java.util.function.LongSupplier;
/** Tick-thread state machine; ports must not perform blocking I/O. */
public final class CarryService {
public record State(double scale, boolean available, boolean free) {}
public interface Port {
State state(UUID player);
boolean nearby(UUID first, UUID second);
boolean mount(UUID carrier, UUID rider);
boolean attached(UUID carrier, UUID rider);
boolean linked(UUID carrier, UUID rider);
boolean dismount(UUID carrier, UUID rider);
void message(UUID player, String message);
}
private record Request(UUID sender, UUID recipient, UUID carrier, UUID rider, long sentAt) {}
private record Ride(UUID carrier, UUID rider) {}
private final Port port;
private final DoubleSupplier step;
private final LongSupplier clock;
private final Map<UUID, Request> requests = new HashMap<>();
private final Map<UUID, Ride> rides = new HashMap<>();
private final java.util.Set<UUID> reportedRefusals = new java.util.HashSet<>();
private boolean closed;
public CarryService(Port port, DoubleSupplier step, LongSupplier clock) {
this.port = port; this.step = step; this.clock = clock;
}
public String request(UUID sender, UUID recipient) {
if (closed) { return "Carrying is shutting down."; }
expireRequests();
if (busy(sender) || busy(recipient)) { return "A player already has a carry request or ride."; }
State a = port.state(sender), b = port.state(recipient);
if (sender.equals(recipient) || a == null || b == null) { return "Players are unavailable."; }
UUID carrier = a.scale() > b.scale() ? sender : recipient;
UUID rider = carrier.equals(sender) ? recipient : sender;
if (!eligible(carrier, rider, true) || !port.nearby(sender, recipient)) {
return "Carry requires nearby, available players differing by at least three adjustment steps.";
}
Request request = new Request(sender, recipient, carrier, rider, clock.getAsLong());
requests.put(recipient, request);
port.message(recipient, "Carry request: /heights carry accept or /heights carry decline within 30 seconds.");
return "Carry request sent.";
}
public String accept(UUID recipient) {
Request request = requests.remove(recipient);
if (request == null) { return "No pending request."; }
if (clock.getAsLong() - request.sentAt() >= 30_000
|| involved(request.carrier()) || involved(request.rider())
|| !eligible(request.carrier(), request.rider(), true)
|| !port.nearby(request.carrier(), request.rider())) {
return "Carry request expired or players are no longer eligible/nearby.";
}
boolean mounted = port.mount(request.carrier(), request.rider());
if (!mounted && !port.linked(request.carrier(), request.rider())) { return "The server refused the ride."; }
Ride ride = new Ride(request.carrier(), request.rider());
rides.put(ride.carrier(), ride); rides.put(ride.rider(), ride);
if (!mounted) {
String stopped = stop(ride.rider());
String message = "The server produced an invalid ride. " + stopped;
if (involved(ride.rider())) { notify(ride, message); }
return message;
}
notify(ride, "Carry started. Either player can /heights carry stop.");
return "Carry started.";
}
private boolean eligible(UUID carrier, UUID rider, boolean free) {
State a = port.state(carrier), b = port.state(rider);
return a != null && b != null && a.available() && b.available()
&& (!free || (a.free() && b.free()))
&& CarryPolicy.eligible(a.scale(), b.scale(), step.getAsDouble());
}
private boolean busy(UUID player) {
return involved(player) || requests.values().stream()
.anyMatch(request -> request.sender().equals(player) || request.recipient().equals(player));
}
private void expireRequests() {
requests.values().removeIf(request -> clock.getAsLong() - request.sentAt() >= 30_000);
}
public String decline(UUID recipient) {
Request request = requests.remove(recipient);
if (request == null) { return "No pending request."; }
port.message(request.sender(), "Carry request declined.");
return "Carry request declined.";
}
public String stop(UUID player) {
var pending = requests.values().iterator();
while (pending.hasNext()) {
Request request = pending.next();
if (request.sender().equals(player) || request.recipient().equals(player)) {
pending.remove();
port.message(request.sender().equals(player) ? request.recipient() : request.sender(),
"Carry request cancelled.");
return "Carry request cancelled.";
}
}
Ride ride = rides.get(player);
if (ride == null) { return "No active ride."; }
if (!port.dismount(ride.carrier(), ride.rider())) {
return "The server refused the dismount. The ride is still active; retry /heights carry stop.";
}
rides.remove(ride.carrier()); rides.remove(ride.rider());
reportedRefusals.remove(ride.rider());
notify(ride, "Carry ended.");
return "Carry ended.";
}
private void notify(Ride ride, String message) {
port.message(ride.carrier(), message); port.message(ride.rider(), message);
}
public void tick() {
expireRequests();
for (Ride ride : java.util.Set.copyOf(rides.values())) {
if (!eligible(ride.carrier(), ride.rider(), false)
|| !port.attached(ride.carrier(), ride.rider())) {
String result = stop(ride.rider());
if (involved(ride.rider()) && reportedRefusals.add(ride.rider())) {
notify(ride, result);
}
}
}
}
public boolean cleanup(UUID player) {
requests.values().removeIf(request -> request.sender().equals(player) || request.recipient().equals(player));
stop(player);
return !involved(player);
}
/** Returns server-refused dismounts; never bypasses cancellation or claims false detachment. */
public int close() {
closed = true;
requests.clear();
for (Ride ride : java.util.Set.copyOf(rides.values())) { stop(ride.rider()); }
return rides.size() / 2;
}
public boolean involved(UUID player) { return rides.containsKey(player); }
}
@@ -17,6 +17,9 @@ public final class HeightsCommand implements TabExecutor {
private final Logger logger;
private final PlayerHeights players;
private final HeightSetter heightSetter;
private final java.util.function.BiConsumer<org.bukkit.entity.Player, String> carry;
private static final String CARRY_USAGE = "Usage: /heights carry accept|decline|stop";
private static final List<String> CARRY_ACTIONS = List.of("accept", "decline", "stop");
@FunctionalInterface
public interface HeightSetter {
@@ -28,14 +31,31 @@ public final class HeightsCommand implements TabExecutor {
}
public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter) {
this(settings, players, logger, heightSetter,
(player, action) -> player.sendMessage("Carrying is unavailable."));
}
public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter,
java.util.function.BiConsumer<org.bukkit.entity.Player, String> carry) {
this.settings = settings;
this.players = players;
this.logger = logger;
this.heightSetter = heightSetter;
this.carry = carry;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length > 0 && args[0].equalsIgnoreCase("carry")) {
if (!(sender instanceof org.bukkit.entity.Player player)) {
sender.sendMessage("Only players can use carry commands.");
} else if (args.length != 2 || !CARRY_ACTIONS.contains(args[1].toLowerCase(Locale.ROOT))) {
sender.sendMessage(CARRY_USAGE);
} else {
carry.accept(player, args[1].toLowerCase(Locale.ROOT));
}
return true;
}
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You do not have permission to manage Spigot Heights.");
return true;
@@ -96,11 +116,16 @@ public final class HeightsCommand implements TabExecutor {
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
boolean player = sender instanceof org.bukkit.entity.Player;
if (player && args.length == 2 && args[0].equalsIgnoreCase("carry")) {
return matching(CARRY_ACTIONS.stream(), args[1]);
}
if (!sender.hasPermission(PERMISSION)) {
return List.of();
return player && args.length == 1 ? matching(Stream.of("carry"), args[0]) : List.of();
}
if (args.length == 1) {
return matching(Stream.of("settings", "set", "player"), args[0]);
return matching(player ? Stream.of("carry", "settings", "set", "player")
: Stream.of("settings", "set", "player"), args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("player")) {
return matching(players.names().stream(), args[1]);
@@ -8,6 +8,7 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotHeightsPlugin extends JavaPlugin {
private BukkitStatureService stature;
private PotionRecipes potions;
private BukkitCarry carry;
@Override
public void onEnable() {
saveDefaultConfig();
@@ -30,7 +31,12 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
BukkitPlayerHeights onlinePlayers = new BukkitPlayerHeights(getServer());
PlayerHeights playerHeights = new PlayerHeights(liveSettings, store, onlinePlayers::find, onlinePlayers::names);
stature = new BukkitStatureService(this, liveSettings, store);
HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, getLogger(), stature::set);
carry = new BukkitCarry(getServer(), liveSettings,
task -> getServer().getScheduler().runTask(this, task), getLogger());
getServer().getPluginManager().registerEvents(carry, this);
// Includes external attribute modifiers and US-009 expiry/admin updates; no movement-event scans.
getServer().getScheduler().runTaskTimer(this, carry::tick, 5L, 5L);
HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, getLogger(), stature::set, carry::command);
PluginCommand command = Objects.requireNonNull(getCommand("heights"), "Missing heights command declaration");
command.setExecutor(executor);
command.setTabCompleter(executor);
@@ -54,6 +60,9 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (carry != null) {
carry.close();
}
if (stature != null) {
stature.close();
}
+2 -3
View File
@@ -6,9 +6,8 @@ description: Craftable player stature potions and tiny-player dispenser launcher
author: dmg.games
commands:
heights:
description: View and change stature limits and online player heights.
usage: /heights settings | /heights set min|max <value> | /heights player <name> [scale]
permission: spigotheights.admin
description: Request consensual carrying or administer stature settings and player heights.
usage: /heights carry accept|decline|stop | /heights settings | /heights set min|max <value> | /heights player <name> [scale]
permissions:
spigotheights.admin:
description: View and change Spigot Heights settings and player heights.
@@ -0,0 +1,290 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import java.util.logging.Logger;
import org.bukkit.*;
import org.bukkit.entity.*;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.junit.jupiter.api.Test;
@SuppressWarnings("removal") // Construct declared-Purpur events without installing a server.
class BukkitCarryTest {
private final UUID worldId = UUID.randomUUID();
private final World world = BukkitDoubles.proxy(World.class, (p, m, a) -> {
if (m.getName().equals("getUID")) { return worldId; }
throw new UnsupportedOperationException(m.getName());
});
private final Actor small = new Actor("Small", 0.4, 0), large = new Actor("Large", 1, 1);
private final List<Runnable> scheduled = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private final Logger logger = new Logger("carry-test", null) {
@Override public void warning(String message) { warnings.add(message); }
@Override public void severe(String message) { warnings.add(message); }
};
private boolean dismountAllowed = true, reenterStop, invalidMountTopology;
private int nativeDismounts;
private final BukkitCarry carry = new BukkitCarry(id -> id.equals(small.id) ? small.player
: id.equals(large.id) ? large.player : null,
() -> new HeightSettings(0.4, 2, 0.1, 0.5, 1.5, 20), scheduled::add, logger,
p -> actor(p).scale, () -> 0);
private Actor actor(Player p) { return p == small.player ? small : large; }
private final class Actor {
final UUID id = UUID.randomUUID();
final List<String> messages = new ArrayList<>();
final List<Entity> passengers = new ArrayList<>();
final Player player;
Entity vehicle;
Location location;
double scale;
boolean sneaking = true, online = true, dead;
Actor(String name, double scale, double x) {
this.scale = scale; location = new Location(world, x, 64, 0);
player = BukkitDoubles.proxy(Player.class, (p, m, a) -> switch (m.getName()) {
case "getUniqueId" -> id;
case "getName" -> name;
case "getWorld" -> world;
case "getLocation" -> location.clone();
case "isOnline", "isValid" -> online;
case "isDead" -> dead;
case "isSleeping" -> false;
case "getGameMode" -> GameMode.SURVIVAL;
case "isSneaking" -> sneaking;
case "isInsideVehicle" -> vehicle != null;
case "getVehicle" -> vehicle;
case "getPassengers" -> List.copyOf(passengers);
case "addPassenger" -> {
Player rider = (Player) a[0]; passengers.add(rider); actor(rider).vehicle = (Entity) p;
if (invalidMountTopology) {
UUID otherId = UUID.randomUUID();
vehicle = BukkitDoubles.proxy(Entity.class, (entity, method, args) -> {
if (method.getName().equals("getUniqueId")) { return otherId; }
throw new UnsupportedOperationException(method.getName());
});
}
yield true;
}
case "leaveVehicle" -> {
nativeDismounts++;
if (reenterStop) {
reenterStop = false;
carry.command((Player) p, "stop");
}
if (vehicle != null) {
var event = new org.bukkit.event.entity.EntityDismountEvent((Player) p, vehicle, true);
if (!dismountAllowed) { event.setCancelled(true); }
carry.dismountEvent(event);
if (!event.isCancelled()) {
actor((Player) vehicle).passengers.removeIf(e -> e == p); vehicle = null;
}
}
yield true; // Intentionally misleading: adapter must check actual native attachment.
}
case "sendMessage" -> { messages.add((String) a[0]); yield null; }
case "equals" -> p == a[0];
case "hashCode" -> id.hashCode();
case "toString" -> name;
case "teleport", "setFallDistance", "setVelocity", "setInvulnerable", "eject", "removePassenger" ->
throw new AssertionError("Native dismount must not rescue, reset motion, or eject other passengers");
default -> throw new UnsupportedOperationException(m.getName());
});
}
}
private PlayerInteractEntityEvent click(Actor from, Actor to, EquipmentSlot hand, boolean cancelled) {
PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(from.player, to.player, hand);
event.setCancelled(cancelled); carry.interact(event); return event;
}
private void start() {
click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept");
assertSame(large.player, small.vehicle);
}
private void drain() {
List<Runnable> tasks = List.copyOf(scheduled); scheduled.clear(); tasks.forEach(Runnable::run);
}
@Test
void startsWithoutSafeGroundAndNativeStopDoesNotTeleportOrResetMotion() {
start();
Location before = small.location.clone();
carry.command(small.player, "stop");
assertEquals(1, nativeDismounts);
assertNull(small.vehicle);
assertEquals(before, small.location);
}
@Test
void carrierCanAlsoStopUsingOnlyTheRidersNativeDismount() {
start();
carry.command(large.player, "stop");
assertEquals(1, nativeDismounts);
assertNull(small.vehicle);
}
@Test
void preventsNestedMountsButDoesNotCancelNaturalSneakDismount() {
start();
var nested = new org.bukkit.event.entity.EntityMountEvent(large.player, small.player);
carry.mountEvent(nested);
assertTrue(nested.isCancelled());
var exit = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, true);
carry.dismountEvent(exit);
assertFalse(exit.isCancelled());
small.player.leaveVehicle();
drain();
assertNull(small.vehicle);
assertEquals(1, nativeDismounts);
start(); // The native detach also cleared Heights' pair tracking.
}
@Test
void allowedTeleportDetachesWithoutCancellingOrReplayingTeleport() {
start();
var event = new org.bukkit.event.player.PlayerTeleportEvent(
large.player, large.location, new Location(world, 50, 70, 50));
event.setCancelled(true); carry.teleportEvent(event); drain();
assertSame(large.player, small.vehicle);
event.setCancelled(false); carry.teleportEvent(event);
assertFalse(event.isCancelled());
assertNull(small.vehicle);
assertEquals(1, nativeDismounts);
assertEquals(50, event.getTo().getX());
}
@Test
void nativeCancellationIsNotMistakenForDetachmentAndCanBeRetried() {
start(); dismountAllowed = false;
carry.command(small.player, "stop");
assertSame(large.player, small.vehicle);
assertTrue(small.messages.stream().anyMatch(m -> m.contains("refused")));
dismountAllowed = true;
carry.tick();
assertEquals(1, nativeDismounts, "A cancelled voluntary stop is not an automatic rescue/retry job");
carry.command(large.player, "stop");
assertNull(small.vehicle);
assertEquals(2, nativeDismounts);
}
@Test
void refusedDismountPreventsTeleportFromMovingTheActivePair() {
start(); dismountAllowed = false;
var event = new org.bukkit.event.player.PlayerTeleportEvent(
large.player, large.location, new Location(world, 50, 70, 50));
carry.teleportEvent(event);
assertTrue(event.isCancelled());
assertSame(large.player, small.vehicle);
}
@Test
void quitCleansBothRolesIncludingAnOfflineRiderWithoutRescue() {
start();
carry.quit(new org.bukkit.event.player.PlayerQuitEvent(large.player, (net.kyori.adventure.text.Component) null));
assertNull(small.vehicle);
start(); small.online = false;
carry.quit(new org.bukkit.event.player.PlayerQuitEvent(small.player, (net.kyori.adventure.text.Component) null));
assertNull(small.vehicle);
assertTrue(warnings.isEmpty());
}
@Test
void cancelledPurpurDeathDoesNotStopRideButActualDeathDoesForEitherRole() {
for (Actor actor : List.of(large, small)) {
start();
var death = new org.bukkit.event.entity.PlayerDeathEvent(
actor.player, null, List.of(), 0, (net.kyori.adventure.text.Component) null, true);
death.setCancelled(true); carry.death(death);
assertSame(large.player, small.vehicle);
death.setCancelled(false); actor.dead = true; carry.death(death);
assertNull(small.vehicle);
actor.dead = false;
}
assertEquals(2, nativeDismounts);
}
@Test
void effectiveScaleChangesUseNativeCleanupAndRespectRefusals() {
start(); small.scale = 0.9; dismountAllowed = false;
carry.tick();
assertSame(large.player, small.vehicle);
dismountAllowed = true; carry.tick();
assertNull(small.vehicle);
}
@Test
void disableUsesNativeDismountAndReportsServerRefusalsWithoutClaimingSuccess() {
start(); dismountAllowed = false;
carry.close();
assertSame(large.player, small.vehicle);
assertTrue(warnings.stream().anyMatch(w -> w.contains("refused")));
dismountAllowed = true; carry.close();
assertNull(small.vehicle);
}
@Test
void nonCancellableNativeDismountNeedsNoRescueOrWarning() {
start();
var event = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, false);
carry.dismountEvent(event);
assertFalse(event.isCancelled());
large.passengers.clear(); small.vehicle = null; // Native forced detachment after event dispatch.
drain();
assertEquals(0, nativeDismounts);
assertTrue(warnings.isEmpty());
start();
}
@Test
void partialNativeMountIsTrackedUntilItsInvalidRelationshipCanBeDetached() {
invalidMountTopology = true; dismountAllowed = false;
click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept");
assertSame(large.player, small.vehicle);
assertEquals(1, nativeDismounts, "Attempt native cleanup of a partially successful invalid mount");
assertFalse(large.messages.getLast().contains("started"));
dismountAllowed = true;
carry.command(small.player, "stop");
assertNull(small.vehicle);
assertEquals(2, nativeDismounts);
assertNotNull(large.vehicle, "Do not eject an unrelated relationship owned by another plugin");
}
@Test
void synchronousPluginReentryCannotRecursivelyDismountTheSameRider() {
start(); reenterStop = true;
carry.command(small.player, "stop");
assertEquals(1, nativeDismounts);
assertNull(small.vehicle);
assertEquals("Carry ended.", small.messages.getLast());
}
@Test
void cancelledNaturalDismountIsNotRetriedAndLateCancellationIsObserved() {
start(); dismountAllowed = false;
small.player.leaveVehicle();
drain();
assertSame(large.player, small.vehicle);
assertEquals(1, nativeDismounts);
var lateCancelled = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, true);
carry.dismountEvent(lateCancelled);
lateCancelled.setCancelled(true);
drain();
assertSame(large.player, small.vehicle);
assertEquals(1, nativeDismounts);
}
@Test
void stalePairDoesNotEjectRiderFromAnUnrelatedVehicle() {
start();
UUID otherId = UUID.randomUUID();
Entity unrelated = BukkitDoubles.proxy(Entity.class, (p, method, args) -> {
if (method.getName().equals("getUniqueId")) { return otherId; }
throw new UnsupportedOperationException(method.getName());
});
large.passengers.clear(); small.vehicle = unrelated;
carry.tick();
assertSame(unrelated, small.vehicle);
assertEquals(0, nativeDismounts);
carry.command(large.player, "stop");
assertEquals("No active ride.", large.messages.getLast());
}
@Test
void onlySneakingUncancelledMainHandRequestsMountAfterAcceptance() {
click(small, large, EquipmentSlot.HAND, true);
click(small, large, EquipmentSlot.OFF_HAND, false);
small.sneaking = false; click(small, large, EquipmentSlot.HAND, false); small.sneaking = true;
assertTrue(large.messages.isEmpty());
assertTrue(click(small, large, EquipmentSlot.HAND, false).isCancelled());
assertFalse(large.messages.isEmpty());
assertTrue(large.passengers.isEmpty());
carry.command(large.player, "accept");
assertSame(large.player, small.vehicle);
assertEquals(List.of(small.player), large.passengers);
carry.command(large.player, "stop");
assertNull(small.vehicle);
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class CarryPolicyTest {
@Test
void requiresThreeCurrentStepsWithDecimalBoundaryTolerance() {
assertTrue(CarryPolicy.eligible(1.0, 0.7, 0.1));
assertTrue(CarryPolicy.eligible(0.7, 0.4, 0.1));
assertFalse(CarryPolicy.eligible(1.0, 0.700001, 0.1));
assertFalse(CarryPolicy.eligible(1.0, 0.7, 0.2));
assertFalse(CarryPolicy.eligible(0.7, 1.0, 0.1));
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY}) {
assertFalse(CarryPolicy.eligible(invalid, 0.4, 0.1));
assertFalse(CarryPolicy.eligible(1, invalid, 0.1));
assertFalse(CarryPolicy.eligible(1, 0.4, invalid));
}
}
}
@@ -0,0 +1,189 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class CarryServiceTest {
private final UUID small = UUID.randomUUID(), large = UUID.randomUUID(), third = UUID.randomUUID();
private final Map<UUID, CarryService.State> states = new HashMap<>(Map.of(
small, new CarryService.State(0.4, true, true),
large, new CarryService.State(1, true, true),
third, new CarryService.State(2, true, true)));
private long now;
private double step = 0.1;
private boolean near = true, mountAllowed = true, stopAllowed = true, attached;
private int mounts, stops;
private UUID mountedCarrier, mountedRider;
private final List<String> messages = new ArrayList<>();
private final CarryService service = new CarryService(new CarryService.Port() {
public CarryService.State state(UUID id) { return states.get(id); }
public boolean nearby(UUID a, UUID b) { return near; }
public boolean mount(UUID carrier, UUID rider) {
mounts++; mountedCarrier = carrier; mountedRider = rider;
attached = mountAllowed; return mountAllowed;
}
public boolean attached(UUID carrier, UUID rider) { return attached; }
public boolean linked(UUID carrier, UUID rider) { return attached; }
public boolean dismount(UUID carrier, UUID rider) {
stops++;
if (stopAllowed) { attached = false; }
return stopAllowed;
}
public void message(UUID id, String text) { messages.add(text); }
}, () -> step, () -> now);
@Test
void consentExpiresAtThirtySecondsAndRechecksLiveConditions() {
service.request(small, large);
now = 30_000;
assertFalse(service.accept(large).contains("started"));
assertEquals(0, mounts);
for (int change = 0; change < 4; change++) {
near = true; step = 0.1;
states.put(small, new CarryService.State(0.4, true, true));
service.request(small, large);
switch (change) {
case 0 -> near = false;
case 1 -> step = 0.3;
case 2 -> states.put(small, new CarryService.State(0.8, true, true));
case 3 -> states.put(small, new CarryService.State(0.4, false, true));
default -> throw new AssertionError();
}
assertFalse(service.accept(large).contains("started"));
assertEquals(0, mounts);
}
near = true; step = 0.1;
states.put(small, new CarryService.State(0.4, true, true));
service.request(small, large);
now += 29_999;
assertTrue(service.accept(large).contains("started"));
}
@Test
void eitherParticipantCanWithdrawPendingConsentWithStop() {
for (UUID caller : List.of(small, large)) {
assertTrue(service.request(small, large).contains("sent"));
assertTrue(service.stop(caller).contains("cancelled"));
assertTrue(service.accept(large).contains("No pending"));
assertEquals(0, mounts);
}
}
@Test
void automaticNativeRefusalIsReportedOnceRatherThanSilentlyRetried() {
service.request(small, large); service.accept(large);
messages.clear();
states.put(small, new CarryService.State(0.9, true, true));
stopAllowed = false;
service.tick(); service.tick();
assertEquals(2, messages.size(), "Notify both participants once, not every tick");
assertTrue(messages.stream().allMatch(message -> message.contains("refused")));
assertTrue(service.involved(small));
stopAllowed = true; service.tick();
assertFalse(service.involved(small));
}
@Test
void reservesOneRequestPerPlayerAndDeclineReleasesBoth() {
assertTrue(service.request(small, large).contains("sent"));
assertFalse(service.request(third, large).contains("sent"));
assertFalse(service.request(small, third).contains("sent"));
assertTrue(service.decline(large).contains("declined"));
assertTrue(service.request(small, third).contains("sent"));
now = 30_000;
service.tick();
assertTrue(service.request(small, large).contains("sent"));
}
@Test
void rejectsExistingPassengersVehiclesAndActiveRidesAndFailedMounts() {
for (UUID busy : List.of(small, large)) {
CarryService.State before = states.get(busy);
states.put(busy, new CarryService.State(before.scale(), true, false));
assertFalse(service.request(small, large).contains("sent"));
states.put(busy, before);
}
service.request(small, large);
states.put(small, new CarryService.State(0.4, true, false));
assertFalse(service.accept(large).contains("started"));
states.put(small, new CarryService.State(0.4, true, true));
service.request(small, large);
mountAllowed = false;
assertFalse(service.accept(large).contains("started"));
assertFalse(service.involved(small));
mountAllowed = true;
service.request(small, large);
service.accept(large);
assertFalse(service.request(third, large).contains("sent"));
assertFalse(service.request(small, third).contains("sent"));
}
@Test
void lossOfEligibilityAttemptsNativeStopAndRefusedVoluntaryStopsRemainAccuratelyTracked() {
service.request(small, large); service.accept(large);
stopAllowed = false;
states.put(small, new CarryService.State(0.9, true, true));
service.tick();
assertEquals(1, stops);
assertTrue(service.involved(small));
assertTrue(service.stop(large).contains("refused"));
// No pending rescue job: if eligibility recovers, a voluntary stop can be requested again.
states.put(small, new CarryService.State(0.4, true, true));
stopAllowed = true;
service.tick();
assertTrue(service.involved(small));
assertTrue(service.stop(small).contains("ended"));
assertFalse(service.involved(small));
assertFalse(service.involved(large));
}
@Test
void lifecycleClearsRequestsAndRidesAndReportsUnresolvedDisable() {
service.request(small, large);
assertTrue(service.cleanup(small));
assertTrue(service.accept(large).contains("No pending"));
service.request(small, large); service.accept(large);
assertTrue(service.cleanup(large));
assertFalse(service.involved(small));
service.request(small, large); service.accept(large);
stopAllowed = false;
assertFalse(service.cleanup(small));
assertEquals(1, service.close());
assertTrue(service.involved(small));
assertFalse(service.request(third, large).contains("sent"));
stopAllowed = true;
assertEquals(0, service.close());
}
@Test
void unavailableOrDetachedParticipantsTriggerCleanup() {
service.request(small, large); service.accept(large);
attached = false;
service.tick();
assertEquals(1, stops);
assertFalse(service.involved(large));
service.request(small, large); service.accept(large);
states.put(large, new CarryService.State(1, false, false));
service.tick();
assertEquals(2, stops);
assertFalse(service.involved(large));
}
@Test
void eitherDirectionRequiresOtherPlayersExplicitConsent() {
for (boolean pickup : new boolean[] {false, true}) {
UUID sender = pickup ? large : small, recipient = pickup ? small : large;
assertTrue(service.request(sender, recipient).contains("sent"));
assertEquals(0, mounts);
assertTrue(service.accept(sender).contains("No pending"));
assertTrue(service.accept(recipient).contains("started"));
assertEquals(large, mountedCarrier);
assertEquals(small, mountedRider);
assertTrue(service.involved(small));
assertTrue(service.involved(large));
assertTrue(service.stop(sender).contains("ended"));
mounts = 0;
}
}
}
@@ -37,6 +37,37 @@ class HeightsCommandTest {
command = new HeightsCommand(live, players, Logger.getAnonymousLogger());
}
@Test
void nonAdminPlayersCanUseOnlySafeCarryActionsAndCompletion() throws Exception {
List<String> actions = new ArrayList<>();
org.bukkit.entity.Player player = BukkitDoubles.proxy(org.bukkit.entity.Player.class, (p, method, args) -> {
if (method.getName().equals("hasPermission")) { return false; }
if (method.getName().equals("sendMessage")) { messages.add((String) args[0]); return null; }
throw new UnsupportedOperationException(method.getName());
});
command = new HeightsCommand(live, players, Logger.getAnonymousLogger(),
(name, value, reply) -> { throw new AssertionError("admin setter reached"); },
(p, action) -> actions.add(action));
for (String action : List.of("accept", "decline", "stop")) {
command.onCommand(player, null, "heights", new String[] {"CaRrY", action.toUpperCase()});
}
assertEquals(List.of("accept", "decline", "stop"), actions);
for (String[] args : new String[][] {{"carry"}, {"carry", "throw"}, {"carry", "stop", "extra"},
{"player", "Steve", "0.5"}, {"settings"}}) {
command.onCommand(player, null, "heights", args);
}
command.onCommand(sender(true), null, "heights", new String[] {"carry", "accept"});
assertEquals(3, actions.size());
assertEquals(List.of("carry"), complete(player, ""));
assertEquals(List.of("accept", "decline", "stop"), complete(player, "carry", ""));
assertEquals(List.of("stop"), complete(player, "carry", "s"));
assertEquals(List.of(), complete(player, "player", ""));
org.bukkit.configuration.file.YamlConfiguration yaml = org.bukkit.configuration.file.YamlConfiguration
.loadConfiguration(new java.io.File("src/main/resources/plugin.yml"));
assertEquals(null, yaml.getString("commands.heights.permission"));
assertEquals("op", yaml.getString("permissions.spigotheights.admin.default"));
}
@Test
void runtimeHeightSetterCanPersistAsynchronouslyWithoutBypassingPermissions() {
List<java.util.function.Consumer<String>> replies = new ArrayList<>();