From 1c226d14ef32066c1bf7321661dd3c870dfd4ea8 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 29 Oct 2026 16:56:29 -0400 Subject: [PATCH] feat(sentinel): detect unsupported boat travel --- README.md | 2 +- .../dmg/purpursentinel/AlertDecision.java | 18 +- .../AtomicYamlSettingsStore.java | 4 + .../dmg/purpursentinel/BoatDetector.java | 194 ++++++++++++++++++ .../dmg/purpursentinel/BoatEvidence.java | 19 ++ .../games/dmg/purpursentinel/BoatFrame.java | 27 +++ .../purpursentinel/BukkitBoatObserver.java | 138 +++++++++++++ .../dmg/purpursentinel/DetectionSignal.java | 5 +- .../dmg/purpursentinel/FlightDetector.java | 3 +- .../purpursentinel/InspectionSnapshot.java | 14 +- .../purpursentinel/MovementEnvironment.java | 21 ++ .../dmg/purpursentinel/MovementMonitor.java | 20 +- .../purpursentinel/PurpurSentinelPlugin.java | 9 + .../dmg/purpursentinel/SentinelAlerts.java | 5 + .../dmg/purpursentinel/SentinelCommand.java | 11 +- .../dmg/purpursentinel/SentinelSettings.java | 26 ++- .../games/dmg/purpursentinel/SettingKey.java | 6 +- .../dmg/purpursentinel/SettingsService.java | 10 +- src/main/resources/config.yml | 4 + .../purpursentinel/PluginLifecycleTest.java | 6 +- .../dmg/purpursentinel/BoatMonitorTest.java | 102 +++++++++ .../dmg/purpursentinel/BoatSupportTest.java | 48 +++++ .../BukkitBoatObserverTest.java | 97 +++++++++ .../dmg/purpursentinel/DistributionTest.java | 4 + .../purpursentinel/SentinelAlertsTest.java | 21 ++ .../purpursentinel/SentinelCommandTest.java | 18 ++ .../purpursentinel/SettingsServiceTest.java | 22 ++ 27 files changed, 832 insertions(+), 22 deletions(-) create mode 100644 src/main/java/games/dmg/purpursentinel/BoatDetector.java create mode 100644 src/main/java/games/dmg/purpursentinel/BoatEvidence.java create mode 100644 src/main/java/games/dmg/purpursentinel/BoatFrame.java create mode 100644 src/main/java/games/dmg/purpursentinel/BukkitBoatObserver.java create mode 100644 src/test/java/games/dmg/purpursentinel/BoatMonitorTest.java create mode 100644 src/test/java/games/dmg/purpursentinel/BoatSupportTest.java create mode 100644 src/test/java/games/dmg/purpursentinel/BukkitBoatObserverTest.java diff --git a/README.md b/README.md index 43213f4..5c64e2f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Passive, explainable movement-pattern observation for Purpur servers. -Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement, contextually impossible ground speed, and sustained unauthorized unsupported flight. Contextual ground-speed evidence uses server-authoritative movement attributes, locomotion state, and Speed/Slowness effects while retaining conservative geometry and lifecycle exclusions. It does not cancel movement, change flight permissions, punish players, persist player evidence, or export telemetry. +Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement, contextually impossible ground speed, sustained unauthorized unsupported flight, and occupied boats or rafts that sustain unsupported ascent, hovering, or level travel. Contextual ground-speed evidence uses server-authoritative movement attributes, locomotion state, and Speed/Slowness effects while vehicle and flight evidence uses bounded loaded-geometry checks and conservative lifecycle exclusions. It does not cancel movement, alter vehicles, change flight permissions, punish players, persist player evidence, or export telemetry. ## Development diff --git a/src/main/java/games/dmg/purpursentinel/AlertDecision.java b/src/main/java/games/dmg/purpursentinel/AlertDecision.java index 19fe390..0b02ae4 100644 --- a/src/main/java/games/dmg/purpursentinel/AlertDecision.java +++ b/src/main/java/games/dmg/purpursentinel/AlertDecision.java @@ -12,34 +12,40 @@ public record AlertDecision( int evidenceWindowSeconds, int eligibleTicks, double horizontalDistance, - double verticalDisplacement) { + double verticalDisplacement, + String vehicleType) { public AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) { this(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, peakSpeed, capturedLimit, - peakSpeed / capturedLimit, 0, 0, 0, 0); + peakSpeed / capturedLimit, 0, 0, 0, 0, null); } public AlertDecision(UUID playerId, DetectionSignal signal, int breachCount, double peakSpeed, double capturedLimit, int eligibleTicks, double horizontalDistance, double verticalDisplacement) { this(playerId, signal, breachCount, peakSpeed, capturedLimit, capturedLimit > 0 ? peakSpeed / capturedLimit : 0, 0, - eligibleTicks, horizontalDistance, verticalDisplacement); + eligibleTicks, horizontalDistance, verticalDisplacement, null); } public static AlertDecision absolute(UUID playerId, int breachCount, double observedSpeed, double capturedLimit, int evidenceWindowSeconds) { return new AlertDecision(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, - observedSpeed, capturedLimit, observedSpeed / capturedLimit, evidenceWindowSeconds, 0, 0, 0); + observedSpeed, capturedLimit, observedSpeed / capturedLimit, evidenceWindowSeconds, 0, 0, 0, null); } public static AlertDecision contextual(UUID playerId, int breachCount, double observedSpeed, double expectedLimit, int evidenceWindowSeconds) { return new AlertDecision(playerId, DetectionSignal.CONTEXTUAL_GROUND_SPEED, breachCount, - observedSpeed, expectedLimit, observedSpeed / expectedLimit, evidenceWindowSeconds, 0, 0, 0); + observedSpeed, expectedLimit, observedSpeed / expectedLimit, evidenceWindowSeconds, 0, 0, 0, null); } public static AlertDecision flight(UUID playerId, FlightEvidence evidence) { return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(), - evidence.horizontalDistance(), evidence.verticalDisplacement()); + evidence.horizontalDistance(), evidence.verticalDisplacement(), null); + } + + public static AlertDecision boat(UUID playerId, BoatEvidence evidence) { + return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(), + evidence.horizontalDistance(), evidence.verticalDisplacement(), evidence.vehicleType()); } } diff --git a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java index 365b2b6..b6378c2 100644 --- a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java +++ b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java @@ -35,6 +35,10 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence { unsupported-cruise-blocks: 10.0 contextual-speed-ratio: 1.35 contextual-minimum-excess: 0.10 + unsupported-boat-ticks: 40 + unsupported-boat-hover-ticks: 30 + unsupported-boat-ascent-blocks: 2.0 + unsupported-boat-cruise-blocks: 12.0 """, StandardOpenOption.CREATE_NEW); } } diff --git a/src/main/java/games/dmg/purpursentinel/BoatDetector.java b/src/main/java/games/dmg/purpursentinel/BoatDetector.java new file mode 100644 index 0000000..46c3c8c --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/BoatDetector.java @@ -0,0 +1,194 @@ +package games.dmg.purpursentinel; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class BoatDetector { + private final Map boats = new HashMap<>(); + private final Map controllerBoats = new HashMap<>(); + + Optional observe(BoatFrame frame, SentinelSettings settings) { + releaseOtherBoat(frame.controllerId(), frame.vehicleId()); + var state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State()); + if (state.controller != null && !state.controller.equals(frame.controllerId())) release(frame.vehicleId()); + state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State()); + controllerBoats.put(frame.controllerId(), frame.vehicleId()); + state.controller = frame.controllerId(); + state.vehicleId = frame.vehicleId(); + state.vehicleType = frame.vehicleType(); + state.expire(frame.observedAtNanos()); + + if (ineligible(frame)) { + state.resetSequence(); + state.supportState = frame.supportState(); + state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE + ? ExclusionReason.ENVIRONMENT : frame.exclusionReason(); + state.graceThroughTick = Math.max(state.graceThroughTick, + frame.tick() + settings.exemptionGraceTicks()); + return Optional.empty(); + } + if (frame.tick() <= state.graceThroughTick) return Optional.empty(); + if (!state.connected(frame)) state.startSequence(frame); + + state.lastTick = frame.tick(); + state.supportState = SupportState.UNSUPPORTED; + state.currentExclusion = ExclusionReason.NONE; + double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ()); + double vertical = frame.toY() - frame.fromY(); + state.eligibleTicks++; + state.horizontalDistance += horizontal; + state.verticalDisplacement = frame.toY() - state.startY; + if (vertical > 0.01) state.ascentTicks++; + if (Math.abs(vertical) <= 0.01 && horizontal <= 0.03) state.hoverTicks++; + if (Math.abs(vertical) <= 0.01 && horizontal > 0.03) state.cruiseTicks++; + state.signal = signal(state, settings); + if (!thresholdReached(state, settings)) return Optional.empty(); + + state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds()); + state.lastEvidence = state.evidence(frame.observedAtNanos()); + state.evidenceFirstObservedAtNanos = state.firstObservedAtNanos; + if (frame.observedAtNanos() < state.cooldownUntilNanos) return Optional.empty(); + state.cooldownUntilNanos = frame.observedAtNanos() + seconds(settings.alertCooldownSeconds()); + state.lastEvidence = state.evidence(frame.observedAtNanos()); + return Optional.of(AlertDecision.boat(frame.controllerId(), state.lastEvidence)); + } + + Optional inspect(UUID playerId, long nowNanos) { + UUID boatId = controllerBoats.get(playerId); + if (boatId == null) return Optional.empty(); + var state = boats.get(boatId); + if (state == null || !playerId.equals(state.controller)) return Optional.empty(); + state.expire(nowNanos); + return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos)); + } + + void release(UUID boatId) { + var state = boats.remove(boatId); + if (state != null && state.controller != null) controllerBoats.remove(state.controller, boatId); + } + + void forget(UUID playerId) { + var boatId = controllerBoats.remove(playerId); + if (boatId != null) boats.remove(boatId); + } + + void cleanup(int currentTick, long nowNanos) { + var iterator = boats.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + var state = entry.getValue(); + state.expire(nowNanos); + if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence(); + if (state.world == null && state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos + && currentTick > state.graceThroughTick) { + controllerBoats.remove(state.controller, entry.getKey()); + iterator.remove(); + } + } + } + + void clear() { boats.clear(); controllerBoats.clear(); } + + private void releaseOtherBoat(UUID controllerId, UUID currentBoat) { + var previous = controllerBoats.get(controllerId); + if (previous != null && !previous.equals(currentBoat)) release(previous); + } + + private static boolean ineligible(BoatFrame frame) { + return frame.supportState() != SupportState.UNSUPPORTED + || frame.exclusionReason() != ExclusionReason.NONE + || !finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ()); + } + + private static DetectionSignal signal(State state, SentinelSettings settings) { + if (state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks()) { + return DetectionSignal.UNSUPPORTED_BOAT_ASCENT; + } + if (state.hoverTicks >= settings.unsupportedBoatHoverTicks()) { + return DetectionSignal.UNSUPPORTED_BOAT_HOVER; + } + return DetectionSignal.UNSUPPORTED_BOAT_CRUISE; + } + + private static boolean thresholdReached(State state, SentinelSettings settings) { + return switch (state.signal) { + case UNSUPPORTED_BOAT_ASCENT -> state.eligibleTicks >= settings.unsupportedBoatTicks() + && state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks(); + case UNSUPPORTED_BOAT_HOVER -> state.hoverTicks >= settings.unsupportedBoatHoverTicks(); + case UNSUPPORTED_BOAT_CRUISE -> state.cruiseTicks >= settings.unsupportedBoatTicks() + && state.horizontalDistance >= settings.unsupportedBoatCruiseBlocks(); + default -> false; + }; + } + + private static long seconds(int value) { return value * 1_000_000_000L; } + private static boolean finite(double... values) { + for (double value : values) if (!Double.isFinite(value)) return false; + return true; + } + + private static final class State { + private UUID vehicleId; + private UUID controller; + private UUID world; + private String vehicleType = "UNKNOWN_BOAT"; + private int lastTick = Integer.MIN_VALUE; + private int graceThroughTick = Integer.MIN_VALUE; + private long firstObservedAtNanos; + private long evidenceFirstObservedAtNanos; + private long evidenceExpiresAtNanos; + private long cooldownUntilNanos; + private double startY; + private int eligibleTicks; + private int ascentTicks; + private int hoverTicks; + private int cruiseTicks; + private double horizontalDistance; + private double verticalDisplacement; + private SupportState supportState = SupportState.UNKNOWN; + private ExclusionReason currentExclusion = ExclusionReason.NONE; + private DetectionSignal signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE; + private BoatEvidence lastEvidence; + + private boolean connected(BoatFrame frame) { + return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick(); + } + private void startSequence(BoatFrame frame) { + resetSequence(); + world = frame.world(); + lastTick = frame.tick() - 1; + firstObservedAtNanos = frame.observedAtNanos(); + startY = frame.fromY(); + } + private void resetSequence() { + world = null; + lastTick = Integer.MIN_VALUE; + firstObservedAtNanos = 0; + startY = 0; + eligibleTicks = ascentTicks = hoverTicks = cruiseTicks = 0; + horizontalDistance = verticalDisplacement = 0; + signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE; + } + private void expire(long nowNanos) { + if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null; + } + private BoatEvidence evidence(long nowNanos) { + var source = lastEvidence; + int ticks = eligibleTicks, climbs = ascentTicks, hovers = hoverTicks, cruises = cruiseTicks; + double horizontal = horizontalDistance, vertical = verticalDisplacement; + DetectionSignal currentSignal = signal; + long first = firstObservedAtNanos; + if (eligibleTicks == 0 && source != null) { + ticks = source.eligibleTicks(); climbs = source.ascentTicks(); hovers = source.hoverTicks(); + cruises = source.cruiseTicks(); horizontal = source.horizontalDistance(); + vertical = source.verticalDisplacement(); currentSignal = source.signal(); + first = evidenceFirstObservedAtNanos; + } + return new BoatEvidence(currentSignal, vehicleId, vehicleType, supportState, ticks, climbs, hovers, + cruises, horizontal, vertical, Math.max(0, nowNanos - first) / 1_000_000_000.0, + Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0, currentExclusion); + } + } +} diff --git a/src/main/java/games/dmg/purpursentinel/BoatEvidence.java b/src/main/java/games/dmg/purpursentinel/BoatEvidence.java new file mode 100644 index 0000000..04621fe --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/BoatEvidence.java @@ -0,0 +1,19 @@ +package games.dmg.purpursentinel; + +import java.util.UUID; + +public record BoatEvidence( + DetectionSignal signal, + UUID vehicleId, + String vehicleType, + SupportState supportState, + int eligibleTicks, + int ascentTicks, + int hoverTicks, + int cruiseTicks, + double horizontalDistance, + double verticalDisplacement, + double evidenceAgeSeconds, + double cooldownRemainingSeconds, + ExclusionReason currentExclusion) { +} diff --git a/src/main/java/games/dmg/purpursentinel/BoatFrame.java b/src/main/java/games/dmg/purpursentinel/BoatFrame.java new file mode 100644 index 0000000..330f956 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/BoatFrame.java @@ -0,0 +1,27 @@ +package games.dmg.purpursentinel; + +import java.util.UUID; + +public record BoatFrame( + int tick, + long observedAtNanos, + UUID vehicleId, + UUID controllerId, + UUID world, + String vehicleType, + double fromX, + double fromY, + double fromZ, + double toX, + double toY, + double toZ, + SupportState supportState, + ExclusionReason exclusionReason) { + + public static BoatFrame unsupported(int tick, long observedAtNanos, UUID vehicleId, UUID controllerId, + UUID world, String vehicleType, double fromX, double fromY, double fromZ, + double toX, double toY, double toZ) { + return new BoatFrame(tick, observedAtNanos, vehicleId, controllerId, world, vehicleType, + fromX, fromY, fromZ, toX, toY, toZ, SupportState.UNSUPPORTED, ExclusionReason.NONE); + } +} diff --git a/src/main/java/games/dmg/purpursentinel/BukkitBoatObserver.java b/src/main/java/games/dmg/purpursentinel/BukkitBoatObserver.java new file mode 100644 index 0000000..16c2044 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/BukkitBoatObserver.java @@ -0,0 +1,138 @@ +package games.dmg.purpursentinel; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.LongSupplier; +import java.util.function.Supplier; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.entity.Boat; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityTeleportEvent; +import org.bukkit.event.vehicle.VehicleBlockCollisionEvent; +import org.bukkit.event.vehicle.VehicleDestroyEvent; +import org.bukkit.event.vehicle.VehicleEnterEvent; +import org.bukkit.event.vehicle.VehicleExitEvent; + +final class BukkitBoatObserver implements Listener { + private final Server server; + private final MovementMonitor monitor; + private final Supplier settings; + private final LongSupplier nanoTime; + private final Function supportState; + private final Map lastSamples = new HashMap<>(); + + BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime) { + this(server, monitor, settings, nanoTime, MovementEnvironment::boatSupportState); + } + + BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime, Function supportState) { + this.server = server; + this.monitor = monitor; + this.settings = settings; + this.nanoTime = nanoTime; + this.supportState = supportState; + } + + void sample() { + int tick = server.getCurrentTick(); + long observedAtNanos = nanoTime.getAsLong(); + var seen = new HashSet(); + for (var world : server.getWorlds()) { + for (Boat boat : world.getEntitiesByClass(Boat.class)) { + if (!isBoatFamily(boat.getType()) || !boat.isValid() || boat.isDead()) { + release(boat.getUniqueId()); + continue; + } + seen.add(boat.getUniqueId()); + var controller = boat.getPassengers().stream().filter(Player.class::isInstance) + .map(Player.class::cast).findFirst().orElse(null); + if (controller == null) { + release(boat.getUniqueId()); + continue; + } + Location current = boat.getLocation(); + if (current == null || current.getWorld() == null) { + release(boat.getUniqueId()); + continue; + } + Sample prior = lastSamples.get(boat.getUniqueId()); + ExclusionReason reason = classify(boat, controller, current, prior); + Location previous = prior == null || !prior.world.equals(current.getWorld().getUID()) + ? current : prior.location; + monitor.record(new BoatFrame(tick, observedAtNanos, boat.getUniqueId(), controller.getUniqueId(), + current.getWorld().getUID(), boat.getType().name(), previous.getX(), previous.getY(), previous.getZ(), + current.getX(), current.getY(), current.getZ(), supportState.apply(current), reason), settings.get()); + lastSamples.put(boat.getUniqueId(), new Sample(current.clone(), current.getWorld().getUID(), + controller.getUniqueId(), current.getBlockX() >> 4, current.getBlockZ() >> 4)); + } + } + for (UUID boatId : new HashSet<>(lastSamples.keySet())) { + if (!seen.contains(boatId)) release(boatId); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEnter(VehicleEnterEvent event) { releaseIfBoat(event.getVehicle()); } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onExit(VehicleExitEvent event) { releaseIfBoat(event.getVehicle()); } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDestroy(VehicleDestroyEvent event) { releaseIfBoat(event.getVehicle()); } + + @EventHandler(priority = EventPriority.MONITOR) + public void onCollision(VehicleBlockCollisionEvent event) { releaseIfBoat(event.getVehicle()); } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onTeleport(EntityTeleportEvent event) { + if (event.getEntity() instanceof Boat boat) release(boat.getUniqueId()); + } + + void clear() { + lastSamples.clear(); + } + + int trackedBoatCount() { return lastSamples.size(); } + + static boolean isBoatFamily(EntityType type) { + String name = type.name(); + return name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT") + || name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT"); + } + + private ExclusionReason classify(Boat boat, Player controller, Location current, Sample prior) { + if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS; + if (prior == null || !prior.world.equals(current.getWorld().getUID()) + || !prior.controller.equals(controller.getUniqueId()) + || prior.chunkX != current.getBlockX() >> 4 || prior.chunkZ != current.getBlockZ() >> 4) { + return ExclusionReason.LIFECYCLE; + } + double displacement = Math.sqrt(prior.location.distanceSquared(current)); + if (!Double.isFinite(displacement) || displacement > 8.0) return ExclusionReason.LIFECYCLE; + var velocity = boat.getVelocity(); + if (velocity != null && (Math.abs(velocity.getX()) > 1.5 || Math.abs(velocity.getY()) > 1.0 + || Math.abs(velocity.getZ()) > 1.5)) return ExclusionReason.VELOCITY; + return ExclusionReason.NONE; + } + + private void releaseIfBoat(org.bukkit.entity.Vehicle vehicle) { + if (vehicle instanceof Boat boat) release(boat.getUniqueId()); + } + + private void release(UUID boatId) { + lastSamples.remove(boatId); + monitor.releaseBoat(boatId); + } + + private record Sample(Location location, UUID world, UUID controller, int chunkX, int chunkZ) {} +} diff --git a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java index bf876db..9aaeec8 100644 --- a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java +++ b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java @@ -6,5 +6,8 @@ public enum DetectionSignal { UNAUTHORIZED_FLIGHT_STATE, UNSUPPORTED_ASCENT, UNSUPPORTED_HOVER, - UNSUPPORTED_CRUISE + UNSUPPORTED_CRUISE, + UNSUPPORTED_BOAT_ASCENT, + UNSUPPORTED_BOAT_HOVER, + UNSUPPORTED_BOAT_CRUISE } diff --git a/src/main/java/games/dmg/purpursentinel/FlightDetector.java b/src/main/java/games/dmg/purpursentinel/FlightDetector.java index 889ac98..87eb8de 100644 --- a/src/main/java/games/dmg/purpursentinel/FlightDetector.java +++ b/src/main/java/games/dmg/purpursentinel/FlightDetector.java @@ -106,7 +106,8 @@ final class FlightDetector { case UNSUPPORTED_HOVER -> state.hoverTicks >= settings.unsupportedHoverTicks(); case UNSUPPORTED_CRUISE -> state.cruiseTicks >= settings.unsupportedFlightTicks() && state.horizontalDistance >= settings.unsupportedCruiseBlocks(); - case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED -> false; + case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED, UNSUPPORTED_BOAT_ASCENT, + UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE -> false; }; } diff --git a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java index c828dd6..01f7460 100644 --- a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java +++ b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java @@ -8,17 +8,25 @@ public record InspectionSnapshot( ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence, - GroundSpeedEvidence contextualEvidence) { + GroundSpeedEvidence contextualEvidence, + BoatEvidence boatEvidence) { public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds) { this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, - currentExclusion, cooldownRemainingSeconds, null, null); + currentExclusion, cooldownRemainingSeconds, null, null, null); } public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence) { this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, - currentExclusion, cooldownRemainingSeconds, flightEvidence, null); + currentExclusion, cooldownRemainingSeconds, flightEvidence, null, null); + } + + public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, + ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence, + GroundSpeedEvidence contextualEvidence) { + this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, + currentExclusion, cooldownRemainingSeconds, flightEvidence, contextualEvidence, null); } } diff --git a/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java b/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java index 8c5fa94..956ee07 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java +++ b/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java @@ -45,6 +45,27 @@ final class MovementEnvironment { return SupportState.UNSUPPORTED; } + static SupportState boatSupportState(Location location) { + var world = location.getWorld(); + if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { + return SupportState.UNKNOWN; + } + int y = (int) Math.floor(location.getY() - 0.01); + boolean supported = false; + for (int x = location.getBlockX() - 1; x <= location.getBlockX() + 1; x++) { + for (int z = location.getBlockZ() - 1; z <= location.getBlockZ() + 1; z++) { + var block = world.getBlockAt(x, y, z); + var type = block.getType(); + if (ALTERING.contains(type)) return SupportState.UNKNOWN; + if (block.isLiquid() || block.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged() + || isIce(type) || type != Material.AIR && !block.isPassable()) { + supported = true; + } + } + } + return supported ? SupportState.SUPPORTED : SupportState.UNSUPPORTED; + } + static boolean isOrdinary(Location location) { var world = location.getWorld(); if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { diff --git a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java index d8f4293..065a50f 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java +++ b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java @@ -11,6 +11,7 @@ public final class MovementMonitor { private static final double CHAIN_TOLERANCE = 1.0e-6; private final MovementDetector detector; private final FlightDetector flightDetector = new FlightDetector(); + private final BoatDetector boatDetector = new BoatDetector(); private final Map pending = new HashMap<>(); private final List flightAlerts = new ArrayList<>(); @@ -22,6 +23,10 @@ public final class MovementMonitor { flightDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add); } + public void record(BoatFrame frame, SentinelSettings settings) { + boatDetector.observe(frame, settings).ifPresent(flightAlerts::add); + } + public void record(UUID playerId, MovementFrame frame, SentinelSettings settings) { var existing = pending.get(playerId); if (existing != null && existing.tick != frame.tick()) { @@ -49,6 +54,7 @@ public final class MovementMonitor { } detector.cleanup(currentTick, nowNanos); flightDetector.cleanup(currentTick, nowNanos); + boatDetector.cleanup(currentTick, nowNanos); return List.copyOf(alerts); } @@ -61,30 +67,38 @@ public final class MovementMonitor { public Optional inspect(UUID playerId, long nowNanos) { var movement = detector.inspect(playerId, nowNanos); var flight = flightDetector.inspect(playerId, nowNanos); - if (flight.isEmpty()) return movement; + var boat = boatDetector.inspect(playerId, nowNanos); + if (flight.isEmpty() && boat.isEmpty()) return movement; if (movement.isEmpty()) { return Optional.of(new InspectionSnapshot(0, 0, 0, 0, - ExclusionReason.NONE, 0, flight.get())); + ExclusionReason.NONE, 0, flight.orElse(null), null, boat.orElse(null))); } var evidence = movement.get(); return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(), - evidence.cooldownRemainingSeconds(), flight.get(), evidence.contextualEvidence())); + evidence.cooldownRemainingSeconds(), flight.orElse(null), evidence.contextualEvidence(), + boat.orElse(null))); } public void forget(UUID playerId) { pending.remove(playerId); detector.forget(playerId); flightDetector.forget(playerId); + boatDetector.forget(playerId); } public void clear() { pending.clear(); detector.clear(); flightDetector.clear(); + boatDetector.clear(); flightAlerts.clear(); } + public void releaseBoat(UUID boatId) { + boatDetector.release(boatId); + } + int trackedPlayerCount() { return detector.trackedPlayerCount(); } diff --git a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java index 25e0fef..2a83bfc 100644 --- a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java +++ b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java @@ -10,6 +10,7 @@ import org.bukkit.scheduler.BukkitTask; public final class PurpurSentinelPlugin extends JavaPlugin { private MovementMonitor monitor; private BukkitMovementListener listener; + private BukkitBoatObserver boatObserver; private BukkitTask tickTask; @Override public void onEnable() { @@ -20,14 +21,17 @@ public final class PurpurSentinelPlugin extends JavaPlugin { var settings = SettingsService.load(new AtomicYamlSettingsStore(configPath)); monitor = new MovementMonitor(new MovementDetector()); listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime); + boatObserver = new BukkitBoatObserver(getServer(), monitor, settings::current, System::nanoTime); var command = Objects.requireNonNull(getCommand("sentinel"), "Missing sentinel command"); var handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime); command.setExecutor(handler); command.setTabCompleter(handler); getServer().getPluginManager().registerEvents(listener, this); + getServer().getPluginManager().registerEvents(boatObserver, this); var alerts = new SentinelAlerts(getServer()); tickTask = getServer().getScheduler().runTaskTimer(this, () -> { listener.sampleFlight(); + boatObserver.sample(); monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send); }, 1L, 1L); } catch (IOException | IllegalArgumentException exception) { @@ -46,6 +50,11 @@ public final class PurpurSentinelPlugin extends JavaPlugin { HandlerList.unregisterAll(listener); listener = null; } + if (boatObserver != null) { + boatObserver.clear(); + HandlerList.unregisterAll(boatObserver); + boatObserver = null; + } if (monitor != null) { monitor.clear(); monitor = null; diff --git a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java index 0653522..4e63aa7 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java @@ -24,6 +24,11 @@ final class SentinelAlerts { "[Sentinel] %s: CONTEXTUAL_GROUND_SPEED; %d breaches in %ds window; observed %.2f blocks/tick vs expected %.2f; ratio %.2f. Use /sentinel inspect %s.", subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(), alert.capturedLimit(), alert.ratio(), subject.getName()); + } else if (alert.vehicleType() != null) { + message = String.format(Locale.ROOT, + "[Sentinel] %s: %s; boat %s; %d unsupported ticks; %.2f horizontal blocks; %.2f ascent blocks. Use /sentinel inspect %s.", + subject.getName(), alert.signal(), alert.vehicleType(), alert.eligibleTicks(), + alert.horizontalDistance(), alert.verticalDisplacement(), subject.getName()); } else { message = String.format(Locale.ROOT, "[Sentinel] %s: %s; %d unsupported ticks; %.2f horizontal blocks; %.2f vertical blocks. Use /sentinel inspect %s.", diff --git a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java index 03c61e1..88bcc22 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java @@ -93,7 +93,16 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { return true; } var evidence = snapshot.get(); - if (evidence.flightEvidence() != null) { + if (evidence.boatEvidence() != null) { + var boat = evidence.boatEvidence(); + sender.sendMessage(String.format(Locale.ROOT, + "%s: %s; boat %s; supported %s; %d eligible ticks; signal counts ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.", + player.get().getName(), boat.signal(), boat.vehicleType(), + yesNo(boat.supportState() == SupportState.SUPPORTED), boat.eligibleTicks(), + boat.ascentTicks(), boat.hoverTicks(), boat.cruiseTicks(), boat.horizontalDistance(), + boat.verticalDisplacement(), boat.evidenceAgeSeconds(), boat.currentExclusion().description(), + boat.cooldownRemainingSeconds())); + } else if (evidence.flightEvidence() != null) { var flight = evidence.flightEvidence(); sender.sendMessage(String.format(Locale.ROOT, "%s: %s; authorized %s; supported %s; %d eligible ticks; signal counts flying %d, ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.", diff --git a/src/main/java/games/dmg/purpursentinel/SentinelSettings.java b/src/main/java/games/dmg/purpursentinel/SentinelSettings.java index 5fe357c..682980a 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelSettings.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelSettings.java @@ -12,12 +12,27 @@ public record SentinelSettings( double unsupportedAscentBlocks, double unsupportedCruiseBlocks, double contextualSpeedRatio, - double contextualMinimumExcess) { + double contextualMinimumExcess, + int unsupportedBoatTicks, + int unsupportedBoatHoverTicks, + double unsupportedBoatAscentBlocks, + double unsupportedBoatCruiseBlocks) { + + public SentinelSettings(double horizontalSpeedLimit, int breachesToAlert, int evidenceWindowSeconds, + int alertCooldownSeconds, double minimumTps, int exemptionGraceTicks, + int unsupportedFlightTicks, int unsupportedHoverTicks, double unsupportedAscentBlocks, + double unsupportedCruiseBlocks, double contextualSpeedRatio, double contextualMinimumExcess) { + this(horizontalSpeedLimit, breachesToAlert, evidenceWindowSeconds, alertCooldownSeconds, + minimumTps, exemptionGraceTicks, unsupportedFlightTicks, unsupportedHoverTicks, + unsupportedAscentBlocks, unsupportedCruiseBlocks, contextualSpeedRatio, contextualMinimumExcess, + 40, 30, 2.0, 12.0); + } public SentinelSettings(double horizontalSpeedLimit, int breachesToAlert, int evidenceWindowSeconds, int alertCooldownSeconds, double minimumTps, int exemptionGraceTicks) { this(horizontalSpeedLimit, breachesToAlert, evidenceWindowSeconds, alertCooldownSeconds, - minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0, 1.35, 0.10); + minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0, 1.35, 0.10, + 40, 30, 2.0, 12.0); } public SentinelSettings { @@ -33,10 +48,15 @@ public record SentinelSettings( requireFiniteRange(unsupportedCruiseBlocks, 2.0, 100.0, "unsupported-cruise-blocks"); requireFiniteRange(contextualSpeedRatio, 1.05, 3.00, "contextual-speed-ratio"); requireFiniteRange(contextualMinimumExcess, 0.01, 1.00, "contextual-minimum-excess"); + requireRange(unsupportedBoatTicks, 10, 200, "unsupported-boat-ticks"); + requireRange(unsupportedBoatHoverTicks, 10, 200, "unsupported-boat-hover-ticks"); + requireFiniteRange(unsupportedBoatAscentBlocks, 0.5, 20.0, "unsupported-boat-ascent-blocks"); + requireFiniteRange(unsupportedBoatCruiseBlocks, 2.0, 100.0, "unsupported-boat-cruise-blocks"); } public static SentinelSettings defaults() { - return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0, 1.35, 0.10); + return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0, 1.35, 0.10, + 40, 30, 2.0, 12.0); } private static void requireRange(int value, int minimum, int maximum, String key) { diff --git a/src/main/java/games/dmg/purpursentinel/SettingKey.java b/src/main/java/games/dmg/purpursentinel/SettingKey.java index 79a8612..8696087 100644 --- a/src/main/java/games/dmg/purpursentinel/SettingKey.java +++ b/src/main/java/games/dmg/purpursentinel/SettingKey.java @@ -16,7 +16,11 @@ public enum SettingKey { UNSUPPORTED_ASCENT_BLOCKS("unsupported-ascent-blocks", "0.5", "20.0", false), UNSUPPORTED_CRUISE_BLOCKS("unsupported-cruise-blocks", "2.0", "100.0", false), CONTEXTUAL_SPEED_RATIO("contextual-speed-ratio", "1.05", "3.00", false), - CONTEXTUAL_MINIMUM_EXCESS("contextual-minimum-excess", "0.01", "1.00", false); + CONTEXTUAL_MINIMUM_EXCESS("contextual-minimum-excess", "0.01", "1.00", false), + UNSUPPORTED_BOAT_TICKS("unsupported-boat-ticks", "10", "200", true), + UNSUPPORTED_BOAT_HOVER_TICKS("unsupported-boat-hover-ticks", "10", "200", true), + UNSUPPORTED_BOAT_ASCENT_BLOCKS("unsupported-boat-ascent-blocks", "0.5", "20.0", false), + UNSUPPORTED_BOAT_CRUISE_BLOCKS("unsupported-boat-cruise-blocks", "2.0", "100.0", false); private final String key; private final String minimum; diff --git a/src/main/java/games/dmg/purpursentinel/SettingsService.java b/src/main/java/games/dmg/purpursentinel/SettingsService.java index 839b228..b8bccdd 100644 --- a/src/main/java/games/dmg/purpursentinel/SettingsService.java +++ b/src/main/java/games/dmg/purpursentinel/SettingsService.java @@ -49,7 +49,11 @@ public final class SettingsService { optionalDecimal(values, SettingKey.UNSUPPORTED_ASCENT_BLOCKS, 2.0), optionalDecimal(values, SettingKey.UNSUPPORTED_CRUISE_BLOCKS, 10.0), optionalDecimal(values, SettingKey.CONTEXTUAL_SPEED_RATIO, 1.35), - optionalDecimal(values, SettingKey.CONTEXTUAL_MINIMUM_EXCESS, 0.10)); + optionalDecimal(values, SettingKey.CONTEXTUAL_MINIMUM_EXCESS, 0.10), + optionalInteger(values, SettingKey.UNSUPPORTED_BOAT_TICKS, 40), + optionalInteger(values, SettingKey.UNSUPPORTED_BOAT_HOVER_TICKS, 30), + optionalDecimal(values, SettingKey.UNSUPPORTED_BOAT_ASCENT_BLOCKS, 2.0), + optionalDecimal(values, SettingKey.UNSUPPORTED_BOAT_CRUISE_BLOCKS, 12.0)); } private static Object parseValue(SettingKey key, String value) { @@ -112,6 +116,10 @@ public final class SettingsService { case UNSUPPORTED_CRUISE_BLOCKS -> decimal(settings.unsupportedCruiseBlocks(), 1); case CONTEXTUAL_SPEED_RATIO -> decimal(settings.contextualSpeedRatio(), 2); case CONTEXTUAL_MINIMUM_EXCESS -> decimal(settings.contextualMinimumExcess(), 2); + case UNSUPPORTED_BOAT_TICKS -> Integer.toString(settings.unsupportedBoatTicks()); + case UNSUPPORTED_BOAT_HOVER_TICKS -> Integer.toString(settings.unsupportedBoatHoverTicks()); + case UNSUPPORTED_BOAT_ASCENT_BLOCKS -> decimal(settings.unsupportedBoatAscentBlocks(), 1); + case UNSUPPORTED_BOAT_CRUISE_BLOCKS -> decimal(settings.unsupportedBoatCruiseBlocks(), 1); }; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4a517ff..f00991f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -10,3 +10,7 @@ unsupported-ascent-blocks: 2.0 unsupported-cruise-blocks: 10.0 contextual-speed-ratio: 1.35 contextual-minimum-excess: 0.10 +unsupported-boat-ticks: 40 +unsupported-boat-hover-ticks: 30 +unsupported-boat-ascent-blocks: 2.0 +unsupported-boat-cruise-blocks: 12.0 diff --git a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java index 43b69fa..e002572 100644 --- a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java @@ -45,6 +45,7 @@ class PluginLifecycleTest { when(server.getTPS()).thenReturn(new double[]{20, 20, 20}); var currentTick = new java.util.concurrent.atomic.AtomicInteger(1); when(server.getCurrentTick()).thenAnswer(call -> currentTick.get()); + when(server.getWorlds()).thenReturn(List.of()); var scheduler = mock(BukkitScheduler.class); when(server.getScheduler()).thenReturn(scheduler); var manager = mock(PluginManager.class); @@ -111,7 +112,10 @@ class PluginLifecycleTest { assertNotNull(tick.get()); assertInstanceOf(SentinelCommand.class, command.getExecutor()); assertSame(command.getExecutor(), command.getTabCompleter()); - assertTrue(Files.readString(directory.toPath().resolve("config.yml")).contains("breaches-to-alert: 6")); + String config = Files.readString(directory.toPath().resolve("config.yml")); + assertTrue(config.contains("breaches-to-alert: 6")); + assertTrue(config.contains("unsupported-boat-ticks: 40")); + verify(manager).registerEvents(isA(BukkitBoatObserver.class), eq(plugin)); for (int sample = 1; sample <= 6; sample++) { currentTick.set(sample); events.call(new PlayerMoveEvent(subject, new Location(world, 0, 64, 0), new Location(world, 1, 64, 0))); diff --git a/src/test/java/games/dmg/purpursentinel/BoatMonitorTest.java b/src/test/java/games/dmg/purpursentinel/BoatMonitorTest.java new file mode 100644 index 0000000..21053c7 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/BoatMonitorTest.java @@ -0,0 +1,102 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class BoatMonitorTest { + private static final UUID WORLD = UUID.randomUUID(); + private static final UUID BOAT = UUID.randomUUID(); + private static final UUID PLAYER = UUID.randomUUID(); + private static final SentinelSettings SETTINGS = SentinelSettings.defaults(); + + @Test void sustainedHoverAlertsCurrentControllerAndExposesBoundedEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + for (int tick = 1; tick <= 30; tick++) { + monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD, + "BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), SETTINGS); + } + + var alert = monitor.finishBefore(31, nanos(31)).getFirst(); + assertEquals(PLAYER, alert.playerId()); + assertEquals(DetectionSignal.UNSUPPORTED_BOAT_HOVER, alert.signal()); + assertEquals("BAMBOO_RAFT", alert.vehicleType()); + assertEquals(30, alert.eligibleTicks()); + var evidence = monitor.inspect(PLAYER, nanos(31)).orElseThrow().boatEvidence(); + assertEquals(BOAT, evidence.vehicleId()); + assertEquals(SupportState.UNSUPPORTED, evidence.supportState()); + assertEquals(30, evidence.hoverTicks()); + } + + @Test void ascentAndLevelTravelHaveIndependentPersistenceThresholds() { + var ascent = new MovementMonitor(new MovementDetector()); + double y = 80; + for (int tick = 1; tick <= 40; tick++) { + ascent.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD, + "OAK_BOAT", 0, y, 0, 0, y + 0.05, 0), SETTINGS); + y += 0.05; + } + assertEquals(DetectionSignal.UNSUPPORTED_BOAT_ASCENT, + ascent.finishBefore(41, nanos(41)).getFirst().signal()); + + var cruise = new MovementMonitor(new MovementDetector()); + double x = 0; + for (int tick = 1; tick <= 40; tick++) { + cruise.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD, + "CHERRY_CHEST_BOAT", x, 80, 0, x + 0.3, 80, 0), SETTINGS); + x += 0.3; + } + var alert = cruise.finishBefore(41, nanos(41)).getFirst(); + assertEquals(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, alert.signal()); + assertEquals(12.0, alert.horizontalDistance(), 1e-9); + } + + @Test void ascentStillRequiresPersistenceEvenWhenDisplacementThresholdIsReachedEarly() { + var monitor = new MovementMonitor(new MovementDetector()); + double y = 80; + for (int tick = 1; tick <= 10; tick++) { + monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD, + "OAK_BOAT", 0, y, 0, 0, y + 0.25, 0), SETTINGS); + y += 0.25; + } + assertTrue(monitor.finishBefore(11, nanos(11)).isEmpty()); + } + + @Test void ordinaryFallSupportUnknownGeometryAndOneSpikeNeverAlert() { + var monitor = new MovementMonitor(new MovementDetector()); + double y = 90; + for (int tick = 1; tick <= 50; tick++) { + monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD, + "OAK_BOAT", 0, y, 0, 0, y - 0.2, 0), SETTINGS); + y -= 0.2; + } + monitor.record(new BoatFrame(51, nanos(51), BOAT, PLAYER, WORLD, "OAK_BOAT", + 0, y, 0, 0, y, 0, SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS); + monitor.record(BoatFrame.unsupported(72, nanos(72), BOAT, PLAYER, WORLD, + "OAK_BOAT", 0, y, 0, 2, y, 0), SETTINGS); + assertTrue(monitor.finishBefore(73, nanos(73)).isEmpty()); + assertTrue(monitor.inspect(PLAYER, nanos(73)).isEmpty()); + } + + @Test void controllerChangesRemovalAndCleanupNeverAttributeFormerPassenger() { + var monitor = new MovementMonitor(new MovementDetector()); + var replacement = UUID.randomUUID(); + for (int tick = 1; tick <= 29; tick++) { + monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD, + "SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS); + } + monitor.releaseBoat(BOAT); + for (int tick = 50; tick <= 79; tick++) { + monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, replacement, WORLD, + "SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS); + } + var alert = monitor.finishBefore(80, nanos(80)).getFirst(); + assertEquals(replacement, alert.playerId()); + assertTrue(monitor.inspect(PLAYER, nanos(80)).isEmpty()); + monitor.clear(); + assertTrue(monitor.inspect(replacement, nanos(80)).isEmpty()); + } + + private static long nanos(int tick) { return tick * 50_000_000L; } +} diff --git a/src/test/java/games/dmg/purpursentinel/BoatSupportTest.java b/src/test/java/games/dmg/purpursentinel/BoatSupportTest.java new file mode 100644 index 0000000..2665a3d --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/BoatSupportTest.java @@ -0,0 +1,48 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.stream.Stream; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class BoatSupportTest { + @ParameterizedTest(name = "{0}") @MethodSource("supportMaterials") + void waterIceAndCollidableTerrainSupportBoats(Material material) { + var world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + var block = mock(Block.class); + when(block.getType()).thenReturn(material); + when(block.isLiquid()).thenReturn(material == Material.WATER); + when(block.isPassable()).thenReturn(material == Material.WATER); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + assertEquals(SupportState.SUPPORTED, MovementEnvironment.boatSupportState(new Location(world, 0.5, 64, 0.5))); + } + + @org.junit.jupiter.api.Test void pistonGeometryIsUnknownRatherThanEvidenceOrSupport() { + var world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true); + var block = mock(Block.class); + when(block.getType()).thenReturn(Material.MOVING_PISTON); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0))); + } + + @org.junit.jupiter.api.Test void unloadedChunksRemainUnknownWithoutBlockReads() { + var world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0))); + verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + + private static Stream supportMaterials() { + return Stream.of(Material.WATER, Material.ICE, Material.PACKED_ICE, Material.BLUE_ICE, + Material.OAK_PLANKS, Material.STONE); + } +} diff --git a/src/test/java/games/dmg/purpursentinel/BukkitBoatObserverTest.java b/src/test/java/games/dmg/purpursentinel/BukkitBoatObserverTest.java new file mode 100644 index 0000000..780d9f2 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/BukkitBoatObserverTest.java @@ -0,0 +1,97 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Boat; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class BukkitBoatObserverTest { + @Test void everyPurpurBoatAndRaftTypeIsRecognized() { + var names = java.util.Arrays.stream(EntityType.values()).map(Enum::name) + .filter(name -> name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT") + || name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT")) + .toList(); + assertFalse(names.isEmpty()); + names.forEach(name -> assertTrue(BukkitBoatObserver.isBoatFamily(EntityType.valueOf(name)), name)); + } + + @Test void samplesServerBoatTrajectoryOncePerTickAndAttributesFirstPlayerPassenger() { + var server = mock(Server.class); + when(server.getCurrentTick()).thenReturn(1, 2); + when(server.getTPS()).thenReturn(new double[]{20, 20, 20}); + var world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + var boat = mock(Boat.class); + when(boat.getUniqueId()).thenReturn(UUID.randomUUID()); + when(boat.getType()).thenReturn(EntityType.BAMBOO_RAFT); + when(boat.isValid()).thenReturn(true); + when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 0.2, 80, 0)); + var controller = mock(Player.class); + when(controller.getUniqueId()).thenReturn(UUID.randomUUID()); + when(boat.getPassengers()).thenReturn(List.of(controller)); + when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat)); + when(server.getWorlds()).thenReturn(List.of(world)); + var monitor = new MovementMonitor(new MovementDetector()); + var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults, + new AtomicLong(50_000_000L)::get, ignored -> SupportState.UNSUPPORTED); + + observer.sample(); + observer.sample(); + + assertEquals(1, observer.trackedBoatCount()); + assertTrue(monitor.finishBefore(3, 150_000_000L).isEmpty()); + verify(boat, never()).setVelocity(any()); + verify(boat, never()).eject(); + verify(boat, never()).remove(); + } + + @Test void lifecycleEventsReleaseBoatStateWithoutMutatingVehicle() { + var server = mock(Server.class); + var monitor = new MovementMonitor(new MovementDetector()); + var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults, + System::nanoTime, ignored -> SupportState.UNSUPPORTED); + var boat = mock(Boat.class); + when(boat.getUniqueId()).thenReturn(UUID.randomUUID()); + observer.onCollision(new org.bukkit.event.vehicle.VehicleBlockCollisionEvent(boat, + mock(org.bukkit.block.Block.class))); + observer.onTeleport(new org.bukkit.event.entity.EntityTeleportEvent(boat, + mock(Location.class), mock(Location.class))); + verify(boat, never()).eject(); + verify(boat, never()).remove(); + verify(boat, never()).teleport(any(Location.class)); + } + + @Test void controllerLossAndInvalidBoatReleaseEvidence() { + var server = mock(Server.class); + when(server.getCurrentTick()).thenReturn(1, 2); + when(server.getTPS()).thenReturn(new double[]{20, 20, 20}); + var world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + var boat = mock(Boat.class); + when(boat.getUniqueId()).thenReturn(UUID.randomUUID()); + when(boat.getType()).thenReturn(EntityType.OAK_BOAT); + when(boat.isValid()).thenReturn(true, false); + when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0)); + var controller = mock(Player.class); + when(controller.getUniqueId()).thenReturn(UUID.randomUUID()); + when(boat.getPassengers()).thenReturn(List.of(controller)); + when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat)); + when(server.getWorlds()).thenReturn(List.of(world)); + var monitor = new MovementMonitor(new MovementDetector()); + var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults, + System::nanoTime, ignored -> SupportState.UNSUPPORTED); + observer.sample(); + observer.sample(); + assertEquals(0, observer.trackedBoatCount()); + assertTrue(monitor.inspect(controller.getUniqueId(), System.nanoTime()).isEmpty()); + } +} diff --git a/src/test/java/games/dmg/purpursentinel/DistributionTest.java b/src/test/java/games/dmg/purpursentinel/DistributionTest.java index 342a84f..62de854 100644 --- a/src/test/java/games/dmg/purpursentinel/DistributionTest.java +++ b/src/test/java/games/dmg/purpursentinel/DistributionTest.java @@ -35,6 +35,10 @@ class DistributionTest { assertTrue(config.contains("unsupported-cruise-blocks: 10.0")); assertTrue(config.contains("contextual-speed-ratio: 1.35")); assertTrue(config.contains("contextual-minimum-excess: 0.10")); + assertTrue(config.contains("unsupported-boat-ticks: 40")); + assertTrue(config.contains("unsupported-boat-hover-ticks: 30")); + assertTrue(config.contains("unsupported-boat-ascent-blocks: 2.0")); + assertTrue(config.contains("unsupported-boat-cruise-blocks: 12.0")); var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class"); assertNotNull(entry); try (var bytes = new DataInputStream(jar.getInputStream(entry))) { diff --git a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java index afe7ff5..22b2a8c 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java @@ -51,6 +51,27 @@ class SentinelAlertsTest { && !message.toLowerCase().contains("cheat"))); } + @Test @SuppressWarnings({"unchecked", "rawtypes"}) + void boatAlertNamesControllerVehicleSignalAndBoundedEvidenceWithoutCoordinates() { + var server = mock(Server.class); + var subject = mock(Player.class); + var operator = mock(Player.class); + var id = UUID.randomUUID(); + when(server.getPlayer(id)).thenReturn(subject); + when(subject.isOnline()).thenReturn(true); + when(subject.getName()).thenReturn("Alex"); + when(operator.hasPermission("sentinel.alerts")).thenReturn(true); + doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers(); + var evidence = new BoatEvidence(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, UUID.randomUUID(), + "BAMBOO_RAFT", SupportState.UNSUPPORTED, 40, 0, 0, 40, 12, 0, 2, 60, + ExclusionReason.NONE); + new SentinelAlerts(server).send(AlertDecision.boat(id, evidence)); + verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_CRUISE") + && message.contains("BAMBOO_RAFT") && message.contains("40 unsupported ticks") + && message.contains("12.00 horizontal") && !message.contains("x=") + && !message.toLowerCase().contains("cheat"))); + } + @Test @SuppressWarnings({"unchecked", "rawtypes"}) void flightAlertNamesSignalAndBoundedDisplacementWithoutAccusingOrReportingCoordinates() { var server = mock(Server.class); diff --git a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java index beac218..62b7f34 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java @@ -71,6 +71,24 @@ class SentinelCommandTest { verify(online, never()).teleport(any(org.bukkit.Location.class)); } + @Test void inspectReportsBoundedBoatEvidenceSupportExclusionAndCooldown() throws Exception { + var fixture = fixture(); + var target = online(fixture.server, "Boater"); + var sender = permitted("sentinel.inspect"); + var boat = UUID.randomUUID(); + var world = UUID.randomUUID(); + for (int tick = 1; tick <= 30; tick++) { + fixture.monitor.record(BoatFrame.unsupported(tick, tick * 50_000_000L, boat, + target.getUniqueId(), world, "BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), fixture.settings.current()); + } + fixture.monitor.finishBefore(31, 1_550_000_000L); + fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Boater"}); + verify(sender).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_HOVER") + && message.contains("BAMBOO_RAFT") && message.contains("supported no") + && message.contains("30 eligible ticks") && message.contains("exclusion none") + && message.contains("cooldown") && !message.contains("80"))); + } + @Test void inspectReportsBoundedFlightStateSignalsAgeAndCooldown() throws Exception { var fixture = fixture(); var target = online(fixture.server, "Flyer"); diff --git a/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java b/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java index 298618f..1927b12 100644 --- a/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java +++ b/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java @@ -112,6 +112,28 @@ class SettingsServiceTest { assertArrayEquals(before, Files.readAllBytes(old)); } + @Test void boatThresholdsDefaultLoadPersistAndRemainBackwardCompatible() throws Exception { + var file = directory.resolve("config.yml"); + AtomicYamlSettingsStore.writeDefaults(file); + var service = SettingsService.load(new AtomicYamlSettingsStore(file)); + assertEquals(40, service.current().unsupportedBoatTicks()); + assertEquals(30, service.current().unsupportedBoatHoverTicks()); + assertEquals(2.0, service.current().unsupportedBoatAscentBlocks(), 1e-9); + assertEquals(12.0, service.current().unsupportedBoatCruiseBlocks(), 1e-9); + assertEquals("50", service.update(SettingKey.UNSUPPORTED_BOAT_TICKS, "50")); + assertEquals("15.0", service.update(SettingKey.UNSUPPORTED_BOAT_CRUISE_BLOCKS, "15")); + assertEquals(service.current(), SettingsService.load(new AtomicYamlSettingsStore(file)).current()); + + var old = directory.resolve("pre-boat.yml"); + Files.writeString(old, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\n" + + "evidence-window-seconds: 10\nalert-cooldown-seconds: 60\n" + + "minimum-tps: 18.0\nexemption-grace-ticks: 20\n"); + byte[] before = Files.readAllBytes(old); + var compatible = SettingsService.load(new AtomicYamlSettingsStore(old)); + assertEquals(40, compatible.current().unsupportedBoatTicks()); + assertArrayEquals(before, Files.readAllBytes(old)); + } + @Test void malformedStartupConfigurationFailsBeforeUse() throws Exception { var file = directory.resolve("config.yml"); AtomicYamlSettingsStore.writeDefaults(file); -- 2.54.0