feat(heights): carry consenting players with native dismounts
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user