1 Commits
Author SHA1 Message Date
dmg a949fd99c4 feat(heights): mount larger players without consent
Release / release (push) Successful in 4m6s
CI / build (push) Successful in 2m7s
2026-09-12 08:35:29 -04:00
16 changed files with 585 additions and 206 deletions
+32 -9
View File
@@ -59,27 +59,43 @@ Heights corrects Purpur 2618's no-op conversion of custom potions without a vani
**Verification:** `nativeBrewingTest` executes real Purpur 2618 brewing cycles with native inventories, recipes, metadata and Paper event dispatch. It covers all four splash/lingering conversions, ingredient safety and cancellation, mixed/ordinary potions, and output compatibility with the stature adapters. World and entity boundaries are test doubles; these checks do not claim native projectile/cloud spawning, client appearance, or live logout/restart gameplay. Those remain supplementary live checks.
## Consensual carrying (US-010; live acceptance pending)
## Ride larger players (US-010)
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:
Right-click a larger player with your **main hand** to mount immediately. Sneaking and consent are
not required. Only the smaller rider may initiate: larger players cannot force pickup of smaller
players. Either participant can end the ride, without administrator permission or rider approval:
```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.
There are no consent requests, expiry timers, or accept/decline commands. Settings and player-height
commands still require `spigotheights.admin`; console cannot carry players. Ride tracking is not
persisted. There is no remount cooldown or opt-out: a smaller player can mount again after ejection.
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.
Both players must be available and within **4 blocks**, in the same world. Their **effective** scale
difference, including attribute modifiers, must meet `carry.minimum-scale-gap` (default **0.1**,
independent of potion adjustment sizes). Zero still requires a strictly larger carrier. Neither
may already have a vehicle or passengers. Heights permits only one rider per carrier and blocks
additional/nested mounts involving an active pair. Ineligible player clicks remain uncancelled for
other plugins; only eligible carry attempts and their same-tick duplicates are consumed.
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.
The rider can **sneak-dismount normally**; either participant can use the stop command. Heights calls only the rider's native `leaveVehicle()` when commanded, 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.
**Verification:** API/domain tests preserve native cleanup and reject rescue/motion-reset calls.
`NativeCarryRoutingTest` registers the production listener through Purpur 2618's actual Paper
plugin manager and dispatches both `PlayerInteractEntityEvent` and `PlayerInteractAtEntityEvent`.
Same-tick duplicate rider/target interactions are coalesced, including refused mounts; a fresh
interaction next tick can retry without a gameplay cooldown. Registered mount/dismount cancellation
and nonadmin stop routing are also covered. Server/player boundaries are doubled; this is not
client-packet generation or live-server evidence. Supplementary Purpur checks remain for empty/occupied
hands, scaled rider positioning and movement, native placement/falling, cancellation interoperability,
both-role death/disconnect, portal/cross-world teleports, disable and potion/admin/expiry eligibility
changes. No throwing, added launch force, player items, storage, opt-out or rescue behavior is included.
## Tiny-player launchers
@@ -94,12 +110,19 @@ height:
minimum: 0.4
maximum: 2.0
adjustment-step: 0.1
carry:
minimum-scale-gap: 0.1
launcher:
maximum-player-scale-exclusive: 0.5
speed: 1.5
cooldown-ticks: 20
```
`carry.minimum-scale-gap` is independent of potion `height.adjustment-step` and defaults to `0.1`,
including existing files without the key. It must be a finite, nonnegative number; invalid values
prevent plugin enable. Zero permits any strictly positive scale difference, **not equal-size riding**.
Edit this setting in the file and restart; min/max commands preserve it.
Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`.
## Commands
@@ -136,7 +159,7 @@ Player names must match an online player exactly (case-insensitive); partial nam
The plugin JAR is written to `build/libs/`.
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.2 build 2618 launcher (SHA-256 verified) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for the native brewing suite. Full verification remains `./gradlew clean check jar`.
`check` includes `nativeBrewingTest`. It downloads the exact Purpur 26.2 build 2618 launcher (SHA-256 verified) and prepares its runtime dependencies with Paperclip's **patch-only** mode under `build/brewing-runtime/`. It does not start a server, open ports, create a world or accept the EULA. Initial/clean builds need network access; the native test JVM permits up to 1 GiB heap. No runtime implementation classes or test dependencies are packaged in the plugin. Run `./gradlew test` for the fast API/domain suite alone, or `./gradlew nativeBrewingTest` for native brewing and registered carry-event routing tests. Full verification remains `./gradlew clean check jar`.
## Releases
@@ -15,7 +15,7 @@ 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. */
/** Bukkit boundary for rider-initiated carrying. 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;
@@ -24,6 +24,8 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
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 record Interaction(UUID rider, UUID carrier) {}
private final java.util.Set<Interaction> interactions = new java.util.HashSet<>();
private UUID mountingCarrier, mountingRider;
private boolean closed;
@@ -31,36 +33,40 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
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) {
Consumer<Runnable> nextTick, Logger logger, ToDoubleFunction<Player> scales) {
this.players = players; this.nextTick = nextTick; this.logger = logger;
this.scales = scales;
service = new CarryService(this, () -> settings.get().adjustmentStep(), clock);
service = new CarryService(this, () -> settings.get().carryMinimumScaleGap());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void interact(PlayerInteractEntityEvent event) {
if (event.isCancelled() || event instanceof PlayerInteractAtEntityEvent
|| event.getHand() != EquipmentSlot.HAND || !event.getPlayer().isSneaking()
if (closed || event.isCancelled() || event.getHand() != EquipmentSlot.HAND
|| !(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."));
}
Interaction interaction = new Interaction(sender.getUniqueId(), target.getUniqueId());
// Consume duplicates of a handled attempt, even if that attempt was refused or mounted.
if (interactions.contains(interaction)) { event.setCancelled(true); return; }
// Do not suppress other plugins' interaction handlers when this is not a valid carry attempt.
if (!service.canAttemptRide(interaction.rider(), interaction.carrier())) { return; }
event.setCancelled(true);
// A client interaction can dispatch AtEntity and then Entity in the same tick.
// Coalesce that pair even on native refusal; this is not a remount cooldown.
interactions.add(interaction);
nextTick.accept(() -> interactions.remove(interaction));
sender.sendMessage(service.ride(interaction.rider(), interaction.carrier()));
}
// AtEntity has its own HandlerList; registering only the superclass method misses it.
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
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";
default -> "Usage: /heights carry stop";
});
}
@Override public CarryService.State state(UUID id) {
@@ -169,6 +175,7 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
public void tick() { if (!closed) { service.tick(); } }
@Override public void close() {
closed = true;
interactions.clear();
int unresolved = service.close();
if (unresolved != 0) { logger.severe("Heights disable: " + unresolved
+ " native dismount(s) were refused by the server; no cancellation was bypassed."); }
@@ -4,13 +4,12 @@ package games.dmg.spigotheights;
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) {
public static boolean eligible(double carrier, double rider, double gap) {
if (!Double.isFinite(carrier) || !Double.isFinite(rider) || !Double.isFinite(gap)
|| carrier <= 0 || rider <= 0 || gap < 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;
return carrier - rider + tolerance >= gap;
}
}
@@ -4,7 +4,6 @@ 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 {
@@ -18,53 +17,44 @@ public final class CarryService {
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 DoubleSupplier gap;
private final Map<UUID, Ride> rides = new HashMap<>();
private final java.util.Set<UUID> mounting = new java.util.HashSet<>();
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 CarryService(Port port, DoubleSupplier gap) {
this.port = port; this.gap = gap;
}
public String request(UUID sender, UUID recipient) {
/** Whether this interaction belongs to Heights; does not mutate or notify either player. */
public boolean canAttemptRide(UUID rider, UUID carrier) {
return !closed && !involved(rider) && !involved(carrier)
&& !mounting.contains(rider) && !mounting.contains(carrier)
&& !rider.equals(carrier) && eligible(carrier, rider, true) && port.nearby(rider, carrier);
}
/** Only the smaller rider may initiate; the target can never force a pickup. */
public String ride(UUID rider, UUID carrier) {
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.";
if (involved(rider) || involved(carrier) || mounting.contains(rider) || mounting.contains(carrier)) {
return "A player already has a ride.";
}
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.";
if (rider.equals(carrier) || !eligible(carrier, rider, true) || !port.nearby(rider, carrier)) {
return "Right-click an available larger player within 4 blocks; the configured scale gap is required.";
}
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());
boolean mounted;
mounting.add(carrier); mounting.add(rider);
try { mounted = port.mount(carrier, rider); }
finally { mounting.remove(carrier); mounting.remove(rider); }
if (!mounted && !port.linked(carrier, rider)) { return "The server refused the ride."; }
Ride ride = new Ride(carrier, rider);
rides.put(ride.carrier(), ride); rides.put(ride.rider(), ride);
if (!mounted) {
if (!mounted || closed) {
String stopped = stop(ride.rider());
String message = "The server produced an invalid ride. " + stopped;
String message = (closed ? "Carrying is shutting down. " : "The server produced an invalid ride. ") + stopped;
if (involved(ride.rider())) { notify(ride, message); }
return message;
}
@@ -76,35 +66,10 @@ public final class CarryService {
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());
&& CarryPolicy.eligible(a.scale(), b.scale(), gap.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())) {
@@ -119,7 +84,6 @@ public final class CarryService {
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())) {
@@ -131,14 +95,12 @@ public final class CarryService {
}
}
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;
}
@@ -6,9 +6,21 @@ public record HeightSettings(
double adjustmentStep,
double launcherThreshold,
double launcherSpeed,
int launcherCooldownTicks) {
int launcherCooldownTicks,
double carryMinimumScaleGap) {
public static final double DEFAULT_CARRY_MINIMUM_SCALE_GAP = 0.1;
public HeightSettings(double minimum, double maximum, double adjustmentStep,
double launcherThreshold, double launcherSpeed, int launcherCooldownTicks) {
this(minimum, maximum, adjustmentStep, launcherThreshold, launcherSpeed, launcherCooldownTicks,
DEFAULT_CARRY_MINIMUM_SCALE_GAP);
}
public HeightSettings {
if (!Double.isFinite(carryMinimumScaleGap) || carryMinimumScaleGap < 0) {
throw new IllegalArgumentException("carry.minimum-scale-gap must be a finite nonnegative number");
}
requirePositiveFinite("height.minimum", minimum);
requirePositiveFinite("height.maximum", maximum);
requirePositiveFinite("height.adjustment-step", adjustmentStep);
@@ -18,8 +18,8 @@ public final class HeightsCommand implements TabExecutor {
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");
private static final String CARRY_USAGE = "Usage: /heights carry stop";
private static final List<String> CARRY_ACTIONS = List.of("stop");
@FunctionalInterface
public interface HeightSetter {
@@ -31,7 +31,7 @@ public final class LiveHeightSettings implements Supplier<HeightSettings> {
bound.equals("min") ? value : current.minimum(),
bound.equals("max") ? value : current.maximum(),
current.adjustmentStep(), current.launcherThreshold(),
current.launcherSpeed(), current.launcherCooldownTicks());
current.launcherSpeed(), current.launcherCooldownTicks(), current.carryMinimumScaleGap());
}
public void setBound(String bound, double value) throws IOException {
@@ -73,12 +73,16 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
}
static HeightSettings loadSettings(FileConfiguration config) {
Object configuredGap = config.get("carry.minimum-scale-gap", HeightSettings.DEFAULT_CARRY_MINIMUM_SCALE_GAP);
if (!(configuredGap instanceof Number gap)) {
throw new IllegalArgumentException("carry.minimum-scale-gap must be a finite nonnegative number");
}
return new HeightSettings(
config.getDouble("height.minimum"),
config.getDouble("height.maximum"),
config.getDouble("height.adjustment-step"),
config.getDouble("launcher.maximum-player-scale-exclusive"),
config.getDouble("launcher.speed"),
config.getInt("launcher.cooldown-ticks"));
config.getInt("launcher.cooldown-ticks"), gap.doubleValue());
}
}
+4
View File
@@ -3,6 +3,10 @@ height:
maximum: 2.0
adjustment-step: 0.1
# Independent of potion adjustment-step. Zero still requires a strictly larger carrier.
carry:
minimum-scale-gap: 0.1
launcher:
maximum-player-scale-exclusive: 0.5
speed: 1.5
+2 -2
View File
@@ -6,8 +6,8 @@ description: Craftable player stature potions and tiny-player dispenser launcher
author: dmg.games
commands:
heights:
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]
description: Stop a ride or administer stature settings and player heights.
usage: /heights carry 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,242 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.papermc.paper.plugin.manager.PaperPluginManagerImpl;
import java.util.*;
import java.util.logging.Logger;
import org.bukkit.*;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
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;
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.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Actual Purpur 2618 listener discovery, distinct handler lists and Paper event dispatch.
* Only server/player boundaries are doubled; no listening server or client packets. */
class NativeCarryRoutingTest {
private static Server previousServer;
private PluginManager events;
private Plugin plugin;
private BukkitCarry carry;
private final List<Runnable> scheduled = new ArrayList<>();
private final Player small = mock(Player.class), large = mock(Player.class);
private final List<String> replies = new ArrayList<>();
private boolean attached, mountAllowed = true;
private int mounts;
private double smallScale = 0.9, largeScale = 1.0;
@BeforeAll
static void rememberServer() { previousServer = Bukkit.getServer(); }
@AfterAll
static void restoreServer() throws Exception { setServer(previousServer); }
private static void setServer(Server server) throws Exception {
// Bukkit's global is restored so the separate existing native brewing suite is untouched.
var field = Bukkit.class.getDeclaredField("server");
field.setAccessible(true);
field.set(null, server);
}
@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);
events = new PaperPluginManagerImpl(server, new SimpleCommandMap(server, new HashMap<>()), null);
when(server.getPluginManager()).thenReturn(events);
plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugin.getName()).thenReturn("SpigotHeights");
when(plugin.getLogger()).thenReturn(Logger.getLogger("NativeCarryRoutingTest"));
var description = new PluginDescriptionFile("SpigotHeights", "test", "unused.Main");
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
for (Player p : List.of(small, large)) {
when(p.getUniqueId()).thenReturn(UUID.randomUUID());
when(p.getWorld()).thenReturn(world);
when(p.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(p.isOnline()).thenReturn(true);
when(p.isValid()).thenReturn(true);
when(p.getGameMode()).thenReturn(GameMode.SURVIVAL);
doAnswer(invocation -> { replies.add(invocation.getArgument(0, String.class)); return null; })
.when(p).sendMessage(anyString());
}
when(small.getVehicle()).thenAnswer(invocation -> attached ? large : null);
when(small.isInsideVehicle()).thenAnswer(invocation -> attached);
when(large.getPassengers()).thenAnswer(invocation -> attached ? List.of(small) : List.of());
when(large.addPassenger(small)).thenAnswer(invocation -> {
mounts++;
var event = new org.bukkit.event.entity.EntityMountEvent(small, large);
events.callEvent(event);
attached = mountAllowed && !event.isCancelled();
return attached;
});
when(small.leaveVehicle()).thenAnswer(invocation -> {
var event = new org.bukkit.event.entity.EntityDismountEvent(small, large, true);
events.callEvent(event);
if (!event.isCancelled()) { attached = false; }
return true; // Intentionally unreliable: production code must inspect attachment.
});
carry = new BukkitCarry(id -> id.equals(small.getUniqueId()) ? small : large,
() -> new HeightSettings(0.4, 2, 0.25, 0.5, 1.5, 20), scheduled::add,
server.getLogger(), p -> p == small ? smallScale : largeScale);
events.registerEvents(carry, plugin);
}
private PlayerInteractEntityEvent click(boolean at, Player sender, Player target, EquipmentSlot hand) {
return at ? new PlayerInteractAtEntityEvent(sender, target, new Vector(), hand)
: new PlayerInteractEntityEvent(sender, target, hand);
}
private void drain() {
var tasks = List.copyOf(scheduled);
scheduled.clear();
tasks.forEach(Runnable::run);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void duplicateEventTypesDoNotRepeatMountAttemptsOrMessagesEvenAfterRefusal(boolean atFirst) {
for (boolean allowed : new boolean[] {false, true}) {
mountAllowed = allowed;
int before = mounts;
var first = click(atFirst, small, large, EquipmentSlot.HAND);
events.callEvent(first);
assertTrue(first.isCancelled());
int messageCount = replies.size();
var duplicate = click(!atFirst, small, large, EquipmentSlot.HAND);
events.callEvent(duplicate);
assertTrue(duplicate.isCancelled(), "A duplicate of a handled interaction stays consumed");
events.callEvent(click(atFirst, small, large, EquipmentSlot.OFF_HAND));
assertEquals(before + 1, mounts, "One native attempt for the duplicate event pair, even on refusal");
assertEquals(messageCount, replies.size(), "Duplicate dispatch must not send another refusal/busy reply");
assertEquals(allowed, attached);
if (attached) { carry.command(large, "stop"); }
drain(); // A fresh interaction next tick may immediately remount: no gameplay cooldown.
}
events.callEvent(click(atFirst, small, large, EquipmentSlot.HAND));
assertTrue(attached);
assertEquals(3, mounts);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void registeredRoutingHonorsCancellationAndOffhandWithoutSwallowingNextValidEvent(boolean at) {
var cancelled = click(at, small, large, EquipmentSlot.HAND);
cancelled.setCancelled(true);
events.callEvent(cancelled);
events.callEvent(click(at, small, large, EquipmentSlot.OFF_HAND));
assertEquals(0, mounts);
assertTrue(replies.isEmpty());
events.callEvent(click(!at, small, large, EquipmentSlot.HAND));
assertTrue(attached);
}
@ParameterizedTest
@ValueSource(strings = {"equal", "larger", "offline", "distant", "already-riding"})
void ineligibleInteractionsRemainAvailableToOtherPlugins(String reason) {
switch (reason) {
case "equal" -> smallScale = largeScale;
case "larger" -> smallScale = largeScale + 0.2;
case "offline" -> when(small.isOnline()).thenReturn(false);
case "distant" -> {
Location distant = new Location(small.getWorld(), 0, 64, 5);
when(small.getLocation()).thenReturn(distant);
}
case "already-riding" -> when(small.isInsideVehicle()).thenReturn(true);
default -> throw new AssertionError(reason);
}
for (boolean at : new boolean[] {true, false}) {
var interaction = click(at, small, large, EquipmentSlot.HAND);
events.callEvent(interaction);
assertFalse(interaction.isCancelled(), "Rejected carry must leave unrelated interaction handlers available");
}
assertEquals(0, mounts);
assertTrue(replies.isEmpty());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void nativeMountCancellationIsRespectedAndFreshInteractionCanRetry(boolean at) {
Listener canceller = new Listener() {};
events.registerEvent(org.bukkit.event.entity.EntityMountEvent.class, canceller, EventPriority.HIGHEST,
(listener, event) -> ((org.bukkit.event.entity.EntityMountEvent) event).setCancelled(true), plugin);
events.callEvent(click(at, small, large, EquipmentSlot.HAND));
assertFalse(attached);
assertEquals(1, mounts);
assertTrue(replies.getLast().contains("refused"));
HandlerList.unregisterAll(canceller);
drain();
events.callEvent(click(at, small, large, EquipmentSlot.HAND));
assertTrue(attached);
assertEquals(2, mounts);
}
@Test
void nonadminCommandRoutingLetsEitherParticipantStopAndNativeSneakDismountClearsTracking() {
var settings = new LiveHeightSettings(new HeightSettings(0.4, 2, 0.1, 0.5, 1.5, 20), ignored -> {});
var command = new HeightsCommand(settings, null, plugin.getLogger(),
(name, value, reply) -> fail("Carry must not reach admin setter"), carry::command);
for (Player participant : List.of(small, large)) {
events.callEvent(click(true, small, large, EquipmentSlot.HAND));
assertTrue(attached);
assertFalse(participant.hasPermission("spigotheights.admin"));
command.onCommand(participant, null, "heights", new String[] {"carry", "stop"});
assertFalse(attached);
drain();
}
events.callEvent(click(false, small, large, EquipmentSlot.HAND));
assertTrue(attached);
small.leaveVehicle(); // Native sneak path: real registered MONITOR observer sees the event.
drain();
events.callEvent(click(true, small, large, EquipmentSlot.HAND));
assertTrue(attached, "Ordinary native detach must release Heights tracking");
assertEquals(4, mounts);
}
@Test
void registeredDismountLateCancellationRetainsRideUntilCarrierRetriesStop() {
events.callEvent(click(true, small, large, EquipmentSlot.HAND));
Listener canceller = new Listener() {};
events.registerEvent(org.bukkit.event.entity.EntityDismountEvent.class, canceller, EventPriority.MONITOR,
(listener, event) -> ((org.bukkit.event.entity.EntityDismountEvent) event).setCancelled(true), plugin);
small.leaveVehicle();
drain();
assertTrue(attached);
carry.command(large, "stop");
assertTrue(attached);
assertTrue(replies.getLast().contains("refused"));
HandlerList.unregisterAll(canceller);
carry.command(large, "stop");
assertFalse(attached);
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void registeredMainHandEventsImmediatelyMountOnlyTheSmallerInitiator(boolean at) {
events.callEvent(click(at, large, small, EquipmentSlot.HAND));
assertEquals(0, mounts, "Larger players cannot initiate pickup");
assertFalse(small.isSneaking());
events.callEvent(click(at, small, large, EquipmentSlot.HAND));
assertTrue(attached, "Registered handler must receive this event without sneaking or acceptance");
assertEquals(1, mounts);
assertTrue(replies.stream().noneMatch(reply -> reply.contains("request")));
}
}
@@ -28,7 +28,7 @@ class BukkitCarryTest {
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);
p -> actor(p).scale);
private Actor actor(Player p) { return p == small.player ? small : large; }
private final class Actor {
@@ -39,18 +39,19 @@ class BukkitCarryTest {
Entity vehicle;
Location location;
double scale;
boolean sneaking = true, online = true, dead;
boolean sneaking, online = true, dead, sleeping;
GameMode gameMode = GameMode.SURVIVAL;
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 "getWorld" -> location.getWorld();
case "getLocation" -> location.clone();
case "isOnline", "isValid" -> online;
case "isDead" -> dead;
case "isSleeping" -> false;
case "getGameMode" -> GameMode.SURVIVAL;
case "isSleeping" -> sleeping;
case "getGameMode" -> gameMode;
case "isSneaking" -> sneaking;
case "isInsideVehicle" -> vehicle != null;
case "getVehicle" -> vehicle;
@@ -97,13 +98,85 @@ class BukkitCarryTest {
event.setCancelled(cancelled); carry.interact(event); return event;
}
private void start() {
click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept");
drain();
click(small, large, EquipmentSlot.HAND, false);
assertSame(large.player, small.vehicle);
}
private void drain() {
List<Runnable> tasks = List.copyOf(scheduled); scheduled.clear(); tasks.forEach(Runnable::run);
}
@Test
void mountingKeepsFourBlockSameWorldAndAvailableParticipantBoundaries() {
large.location.setX(4.000001);
click(small, large, EquipmentSlot.HAND, false);
assertNull(small.vehicle);
drain();
large.location.setX(4);
start();
carry.command(small.player, "stop");
drain();
UUID otherWorldId = UUID.randomUUID();
World otherWorld = BukkitDoubles.proxy(World.class, (p, m, a) -> {
if (m.getName().equals("getUID")) { return otherWorldId; }
throw new UnsupportedOperationException(m.getName());
});
large.location.setWorld(otherWorld);
click(small, large, EquipmentSlot.HAND, false);
assertNull(small.vehicle);
large.location.setWorld(world);
for (Actor unavailable : List.of(small, large)) {
for (int state = 0; state < 4; state++) {
drain();
unavailable.online = state != 0;
unavailable.dead = state == 1;
unavailable.sleeping = state == 2;
unavailable.gameMode = state == 3 ? GameMode.SPECTATOR : GameMode.SURVIVAL;
click(small, large, EquipmentSlot.HAND, false);
assertNull(small.vehicle);
}
unavailable.online = true; unavailable.dead = false; unavailable.sleeping = false;
unavailable.gameMode = GameMode.SURVIVAL;
}
start();
}
@Test
void existingVehiclesOrPassengersOfEitherParticipantAreNeverTouched() {
for (Actor busy : List.of(small, large)) {
Actor other = busy == small ? large : small;
busy.vehicle = other.player;
click(small, large, EquipmentSlot.HAND, false);
assertSame(other.player, busy.vehicle);
assertEquals(0, nativeDismounts);
busy.vehicle = null;
drain();
busy.passengers.add(other.player);
click(small, large, EquipmentSlot.HAND, false);
assertEquals(List.of(other.player), busy.passengers);
assertNull(small.vehicle);
assertEquals(0, nativeDismounts);
busy.passengers.clear();
drain();
}
start();
// Additional passengers/mounts involving either occupied role are rejected.
UUID extraId = UUID.randomUUID();
Entity extra = BukkitDoubles.proxy(Entity.class, (p, m, a) -> {
if (m.getName().equals("getUniqueId")) { return extraId; }
throw new UnsupportedOperationException(m.getName());
});
var extraRider = new org.bukkit.event.entity.EntityMountEvent(extra, large.player);
carry.mountEvent(extraRider);
assertTrue(extraRider.isCancelled());
}
@Test
void removedConsentActionsOnlyShowStopUsage() {
for (String action : List.of("accept", "decline", "unknown")) {
carry.command(large.player, action);
assertEquals("Usage: /heights carry stop", large.messages.getLast());
assertNull(small.vehicle);
}
}
@Test
void startsWithoutSafeGroundAndNativeStopDoesNotTeleportOrResetMotion() {
start();
Location before = small.location.clone();
@@ -195,7 +268,7 @@ class BukkitCarryTest {
}
@Test
void effectiveScaleChangesUseNativeCleanupAndRespectRefusals() {
start(); small.scale = 0.9; dismountAllowed = false;
start(); small.scale = 0.95; dismountAllowed = false;
carry.tick();
assertSame(large.player, small.vehicle);
dismountAllowed = true; carry.tick();
@@ -225,7 +298,7 @@ class BukkitCarryTest {
@Test
void partialNativeMountIsTrackedUntilItsInvalidRelationshipCanBeDetached() {
invalidMountTopology = true; dismountAllowed = false;
click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept");
click(small, large, EquipmentSlot.HAND, false);
assertSame(large.player, small.vehicle);
assertEquals(1, nativeDismounts, "Attempt native cleanup of a partially successful invalid mount");
assertFalse(large.messages.getLast().contains("started"));
@@ -273,15 +346,13 @@ class BukkitCarryTest {
assertEquals("No active ride.", large.messages.getLast());
}
@Test
void onlySneakingUncancelledMainHandRequestsMountAfterAcceptance() {
void onlyUncancelledMainHandSmallerInitiatorsMountImmediately() {
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());
click(large, small, EquipmentSlot.HAND, false);
assertNull(small.vehicle);
assertTrue(large.passengers.isEmpty());
carry.command(large.player, "accept");
assertTrue(click(small, large, EquipmentSlot.HAND, false).isCancelled());
assertSame(large.player, small.vehicle);
assertEquals(List.of(small.player), large.passengers);
carry.command(large.player, "stop");
@@ -5,16 +5,24 @@ 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));
void usesIndependentGapWithDecimalBoundaryTolerance() {
assertTrue(CarryPolicy.eligible(1.0, 0.9, 0.1));
assertTrue(CarryPolicy.eligible(0.5, 0.4, 0.1));
assertFalse(CarryPolicy.eligible(1.0, 0.900001, 0.1));
assertFalse(CarryPolicy.eligible(1.0, 0.9, 0.2));
assertTrue(CarryPolicy.eligible(1.0, 0.8, 0.2));
assertFalse(CarryPolicy.eligible(0.7, 1.0, 0.1));
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY}) {
}
@Test
void zeroGapStillRequiresStrictlyLargerCarrierAndAllInputsMustBeValid() {
assertTrue(CarryPolicy.eligible(1, Math.nextDown(1.0), 0));
assertFalse(CarryPolicy.eligible(1, 1, 0));
assertFalse(CarryPolicy.eligible(0.9, 1, 0));
for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) {
assertFalse(CarryPolicy.eligible(invalid, 0.4, 0.1));
assertFalse(CarryPolicy.eligible(1, invalid, 0.1));
assertFalse(CarryPolicy.eligible(1, 0.4, invalid));
if (invalid != 0) { assertFalse(CarryPolicy.eligible(1, 0.4, invalid)); }
}
}
}
@@ -10,10 +10,10 @@ class CarryServiceTest {
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 double gap = 0.1;
private boolean near = true, mountAllowed = true, stopAllowed = true, attached;
private int mounts, stops;
private Runnable onMount = () -> {};
private UUID mountedCarrier, mountedRider;
private final List<String> messages = new ArrayList<>();
private final CarryService service = new CarryService(new CarryService.Port() {
@@ -21,6 +21,7 @@ class CarryServiceTest {
public boolean nearby(UUID a, UUID b) { return near; }
public boolean mount(UUID carrier, UUID rider) {
mounts++; mountedCarrier = carrier; mountedRider = rider;
onMount.run();
attached = mountAllowed; return mountAllowed;
}
public boolean attached(UUID carrier, UUID rider) { return attached; }
@@ -31,50 +32,80 @@ class CarryServiceTest {
return stopAllowed;
}
public void message(UUID id, String text) { messages.add(text); }
}, () -> step, () -> now);
}, () -> gap);
@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"));
void shutdownDuringNativeMountDetachesInsteadOfStartingANewRide() {
onMount = () -> service.close();
String result = service.ride(small, large);
assertFalse(result.contains("started"));
assertEquals(1, stops);
assertFalse(service.involved(small));
assertFalse(attached);
}
@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"));
void shutdownDuringMountStillReportsRefusedNativeDetachmentHonestly() {
stopAllowed = false;
onMount = () -> service.close();
String result = service.ride(small, large);
assertFalse(result.contains("started"));
assertTrue(result.contains("refused"));
assertTrue(service.involved(small));
assertTrue(attached);
assertEquals(1, service.close());
stopAllowed = true;
assertEquals(0, service.close());
}
@Test
void synchronousMountCallbacksCannotStartAnotherRideForEitherParticipant() {
onMount = () -> {
onMount = () -> {};
assertFalse(service.ride(small, third).contains("started"));
assertFalse(service.ride(large, third).contains("started"));
};
assertTrue(service.ride(small, large).contains("started"));
assertEquals(1, mounts);
assertEquals(large, mountedCarrier);
assertEquals(small, mountedRider);
assertFalse(service.involved(third));
}
@Test
void immediateRideIsSmallerInitiatedOnlyAndChecksCurrentEligibility() {
assertFalse(service.ride(large, small).contains("started"));
assertEquals(0, mounts);
for (int change = 0; change < 5; change++) {
near = true; gap = 0.1;
states.put(small, new CarryService.State(0.4, true, true));
switch (change) {
case 0 -> near = false;
case 1 -> gap = 0.7;
case 2 -> states.put(small, new CarryService.State(0.95, true, true));
case 3 -> states.put(small, new CarryService.State(0.4, false, true));
case 4 -> states.remove(small);
default -> throw new AssertionError();
}
assertFalse(service.ride(small, large).contains("started"));
assertEquals(0, mounts);
}
near = true; gap = 0.1;
states.put(small, new CarryService.State(0.9, true, true));
assertTrue(service.ride(small, large).contains("started"));
assertEquals(1, mounts);
assertEquals(large, mountedCarrier);
assertEquals(small, mountedRider);
assertTrue(service.involved(small));
assertTrue(service.involved(large));
assertTrue(messages.stream().noneMatch(message -> message.contains("request")));
}
@Test
void automaticNativeRefusalIsReportedOnceRatherThanSilentlyRetried() {
service.request(small, large); service.accept(large);
service.ride(small, large);
messages.clear();
states.put(small, new CarryService.State(0.9, true, true));
states.put(small, new CarryService.State(0.95, true, true));
stopAllowed = false;
service.tick(); service.tick();
assertEquals(2, messages.size(), "Notify both participants once, not every tick");
@@ -84,51 +115,35 @@ class CarryServiceTest {
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"));
assertFalse(service.ride(small, large).contains("started"));
assertEquals(0, mounts);
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"));
assertTrue(service.ride(small, large).contains("refused"));
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"));
assertTrue(service.ride(small, large).contains("started"));
assertFalse(service.ride(third, large).contains("started"));
assertFalse(service.ride(small, third).contains("started"));
assertFalse(service.ride(large, third).contains("started"));
assertEquals(2, mounts);
}
@Test
void lossOfEligibilityAttemptsNativeStopAndRefusedVoluntaryStopsRemainAccuratelyTracked() {
service.request(small, large); service.accept(large);
service.ride(small, large);
stopAllowed = false;
states.put(small, new CarryService.State(0.9, true, true));
states.put(small, new CarryService.State(0.95, 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();
@@ -139,31 +154,30 @@ class CarryServiceTest {
}
@Test
void lifecycleClearsRequestsAndRidesAndReportsUnresolvedDisable() {
service.request(small, large);
void lifecycleCleansRidesAndReportsUnresolvedDisable() {
service.ride(small, large);
assertTrue(service.cleanup(small));
assertTrue(service.accept(large).contains("No pending"));
service.request(small, large); service.accept(large);
service.ride(small, large);
assertTrue(service.cleanup(large));
assertFalse(service.involved(small));
service.request(small, large); service.accept(large);
service.ride(small, large);
stopAllowed = false;
assertFalse(service.cleanup(small));
assertEquals(1, service.close());
assertTrue(service.involved(small));
assertFalse(service.request(third, large).contains("sent"));
assertFalse(service.ride(third, large).contains("started"));
stopAllowed = true;
assertEquals(0, service.close());
}
@Test
void unavailableOrDetachedParticipantsTriggerCleanup() {
service.request(small, large); service.accept(large);
service.ride(small, large);
attached = false;
service.tick();
assertEquals(1, stops);
assertFalse(service.involved(large));
service.request(small, large); service.accept(large);
service.ride(small, large);
states.put(large, new CarryService.State(1, false, false));
service.tick();
assertEquals(2, stops);
@@ -171,19 +185,13 @@ class CarryServiceTest {
}
@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;
void eitherParticipantCanStopImmediatelyWithoutApprovalOrRemountCooldown() {
for (UUID caller : List.of(small, large)) {
assertTrue(service.ride(small, large).contains("started"));
assertTrue(service.stop(caller).contains("ended"));
assertFalse(service.involved(small));
}
assertEquals(2, mounts);
assertEquals(2, stops);
}
}
@@ -13,6 +13,38 @@ class HeightSettingsTest {
assertEquals(16, settings.randomStepCount());
}
@Test
void missingGapDefaultsIndependentlyAndBoundsKeepExplicitGap() throws Exception {
var config = new org.bukkit.configuration.file.YamlConfiguration();
config.loadFromString(java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/config.yml")));
assertEquals(0.1, SpigotHeightsPlugin.loadSettings(config).carryMinimumScaleGap());
config.set("carry.minimum-scale-gap", null);
config.set("height.adjustment-step", 0.25);
assertEquals(0.1, SpigotHeightsPlugin.loadSettings(config).carryMinimumScaleGap());
for (double gap : new double[] {0, 0.2, 1.5}) {
config.set("carry.minimum-scale-gap", gap);
HeightSettings settings = SpigotHeightsPlugin.loadSettings(config);
assertEquals(gap, settings.carryMinimumScaleGap());
assertEquals(0.25, settings.adjustmentStep());
var live = new LiveHeightSettings(settings, ignored -> {});
live.setBound("max", 3);
assertEquals(gap, live.get().carryMinimumScaleGap());
assertEquals(0.25, live.get().adjustmentStep());
}
}
@Test
void rejectsInvalidCarryGapInExistingConfigLoader() throws Exception {
var config = new org.bukkit.configuration.file.YamlConfiguration();
config.loadFromString(java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/config.yml")));
for (Object invalid : new Object[] {-0.1, Double.NaN, Double.POSITIVE_INFINITY,
Double.NEGATIVE_INFINITY, "garbage", true, java.util.List.of(0.1)}) {
config.set("carry.minimum-scale-gap", invalid);
assertThrows(IllegalArgumentException.class, () -> SpigotHeightsPlugin.loadSettings(config),
"Invalid carry gap must fail startup validation: " + invalid);
}
}
@Test
void rejectsInvalidRangesAndNumbers() {
assertThrows(IllegalArgumentException.class,
@@ -51,19 +51,26 @@ class HeightsCommandTest {
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);
assertEquals(List.of("stop"), actions);
assertTrue(messages.stream().allMatch(message -> message.equals("Usage: /heights carry stop")));
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(1, actions.size());
assertEquals(List.of("carry"), complete(player, ""));
assertEquals(List.of("accept", "decline", "stop"), complete(player, "carry", ""));
assertEquals(List.of("stop"), complete(player, "carry", ""));
assertEquals(List.of(), complete(player, "carry", "a"));
assertEquals(List.of(), complete(player, "carry", "d"));
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"));
assertTrue(yaml.getString("commands.heights.usage").contains("carry stop"));
assertTrue(!yaml.getString("commands.heights.usage").contains("accept"));
assertTrue(!yaml.getString("commands.heights.usage").contains("decline"));
assertTrue(!yaml.getString("commands.heights.description").contains("consensual"));
assertEquals(null, yaml.getString("commands.heights.permission"));
assertEquals("op", yaml.getString("permissions.spigotheights.admin.default"));
}