diff --git a/README.md b/README.md index a34b4f1..322405e 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. It does not cancel movement, punish players, persist player evidence, or export telemetry. +Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement and sustained unauthorized unsupported flight. It does not cancel movement, 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 05d9539..62c05ba 100644 --- a/src/main/java/games/dmg/purpursentinel/AlertDecision.java +++ b/src/main/java/games/dmg/purpursentinel/AlertDecision.java @@ -2,5 +2,22 @@ package games.dmg.purpursentinel; import java.util.UUID; -public record AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) { +public record AlertDecision( + UUID playerId, + DetectionSignal signal, + int breachCount, + double peakSpeed, + double capturedLimit, + int eligibleTicks, + double horizontalDistance, + double verticalDisplacement) { + + public AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) { + this(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, peakSpeed, capturedLimit, 0, 0, 0); + } + + public static AlertDecision flight(UUID playerId, FlightEvidence evidence) { + return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, evidence.eligibleTicks(), + evidence.horizontalDistance(), evidence.verticalDisplacement()); + } } diff --git a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java index fd5e1a9..9e4b79b 100644 --- a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java +++ b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java @@ -29,6 +29,10 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence { alert-cooldown-seconds: 60 minimum-tps: 18.0 exemption-grace-ticks: 20 + unsupported-flight-ticks: 40 + unsupported-hover-ticks: 30 + unsupported-ascent-blocks: 2.0 + unsupported-cruise-blocks: 10.0 """, StandardOpenOption.CREATE_NEW); } } diff --git a/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java b/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java index fcc7924..e389612 100644 --- a/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java +++ b/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java @@ -1,7 +1,11 @@ package games.dmg.purpursentinel; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.BiPredicate; +import java.util.function.Function; import java.util.function.LongSupplier; import java.util.function.Supplier; import org.bukkit.GameMode; @@ -28,19 +32,29 @@ public final class BukkitMovementListener implements Listener { private final Supplier settings; private final LongSupplier nanoTime; private final BiPredicate ordinaryEnvironment; + private final Function supportState; + private final Map lastFlightSamples = new HashMap<>(); public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime) { this(server, monitor, settings, nanoTime, - (from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to)); + (from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to), + MovementEnvironment::supportState); } BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime, BiPredicate ordinaryEnvironment) { + this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState); + } + + BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime, BiPredicate ordinaryEnvironment, + Function supportState) { this.server = server; this.monitor = monitor; this.settings = settings; this.nanoTime = nanoTime; this.ordinaryEnvironment = ordinaryEnvironment; + this.supportState = supportState; } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) @@ -84,9 +98,36 @@ public final class BukkitMovementListener implements Listener { } @EventHandler(priority = EventPriority.MONITOR) - public void onQuit(PlayerQuitEvent event) { monitor.forget(event.getPlayer().getUniqueId()); } + public void onQuit(PlayerQuitEvent event) { + lastFlightSamples.remove(event.getPlayer().getUniqueId()); + monitor.forget(event.getPlayer().getUniqueId()); + } + + public void sampleFlight() { + int tick = server.getCurrentTick(); + long observedAtNanos = nanoTime.getAsLong(); + for (Player player : server.getOnlinePlayers()) { + Location current = player.getLocation(); + if (current == null || current.getWorld() == null) continue; + Location previous = lastFlightSamples.put(player.getUniqueId(), current.clone()); + if (previous == null || previous.getWorld() == null || !previous.getWorld().equals(current.getWorld())) { + previous = current; + } + ExclusionReason reason = classify(player, previous, current, false); + monitor.record(player.getUniqueId(), new FlightFrame( + tick, observedAtNanos, current.getWorld().getUID(), + previous.getX(), previous.getY(), previous.getZ(), + current.getX(), current.getY(), current.getZ(), + authorizedFlight(player), player.isFlying(), supportState.apply(current), reason), settings.get()); + } + } + + public void clear() { + lastFlightSamples.clear(); + } private void exempt(Player player, ExclusionReason reason) { + lastFlightSamples.remove(player.getUniqueId()); monitor.exempt(player.getUniqueId(), server.getCurrentTick(), nanoTime.getAsLong(), reason, settings.get()); } @@ -97,20 +138,27 @@ public final class BukkitMovementListener implements Listener { if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS; if (player.getGameMode() != GameMode.SURVIVAL && player.getGameMode() != GameMode.ADVENTURE) return ExclusionReason.GAME_MODE; if (player.isInsideVehicle()) return ExclusionReason.VEHICLE; - if (player.isFlying()) return ExclusionReason.FLYING; + if (player.getAllowFlight()) return ExclusionReason.FLYING; if (player.isGliding() || player.getPose() == Pose.FALL_FLYING) return ExclusionReason.GLIDING; if (player.isSwimming() || player.getPose() == Pose.SWIMMING || player.isInWater() || player.isInLava() || player.isInBubbleColumn()) return ExclusionReason.SWIMMING; if (player.isClimbing()) return ExclusionReason.CLIMBING; if (player.isRiptiding() || player.getPose() == Pose.SPIN_ATTACK) return ExclusionReason.RIPTIDE; if (player.getActivePotionEffects().stream().map(effect -> effect.getType().getKey().getKey()) - .anyMatch(key -> key.equals("speed") || key.equals("slowness"))) { + .anyMatch(key -> key.equals("speed") || key.equals("slowness") + || key.equals("jump_boost") || key.equals("levitation") || key.equals("slow_falling"))) { return ExclusionReason.POTION_EFFECT; } if (!ordinaryEnvironment.test(from, to)) return ExclusionReason.ENVIRONMENT; return ExclusionReason.NONE; } + private static boolean authorizedFlight(Player player) { + return player.getGameMode() == GameMode.CREATIVE + || player.getGameMode() == GameMode.SPECTATOR + || player.getAllowFlight(); + } + private static boolean finite(double... values) { for (double value : values) if (!Double.isFinite(value)) return false; return true; diff --git a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java new file mode 100644 index 0000000..599a9bf --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java @@ -0,0 +1,9 @@ +package games.dmg.purpursentinel; + +public enum DetectionSignal { + ABSOLUTE_GROUND_SPEED, + UNAUTHORIZED_FLIGHT_STATE, + UNSUPPORTED_ASCENT, + UNSUPPORTED_HOVER, + UNSUPPORTED_CRUISE +} diff --git a/src/main/java/games/dmg/purpursentinel/FlightDetector.java b/src/main/java/games/dmg/purpursentinel/FlightDetector.java new file mode 100644 index 0000000..b07d693 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/FlightDetector.java @@ -0,0 +1,206 @@ +package games.dmg.purpursentinel; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class FlightDetector { + private final Map players = new HashMap<>(); + + Optional observe(UUID playerId, FlightFrame frame, SentinelSettings settings) { + var state = players.computeIfAbsent(playerId, ignored -> new State()); + state.expire(frame.observedAtNanos()); + if (ineligible(frame)) { + state.resetSequence(); + state.authorized = frame.authorized(); + state.supported = frame.supportState() == SupportState.SUPPORTED; + state.currentExclusion = frame.exclusionReason() != ExclusionReason.NONE + ? frame.exclusionReason() : frame.authorized() ? ExclusionReason.FLYING : ExclusionReason.ENVIRONMENT; + 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.authorized = false; + state.supported = false; + state.currentExclusion = ExclusionReason.NONE; + state.eligibleTicks++; + double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ()); + double vertical = frame.toY() - frame.fromY(); + state.horizontalDistance += horizontal; + state.verticalDisplacement = frame.toY() - state.startY; + if (frame.flying()) state.unauthorizedFlightTicks++; + 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, frame, 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.flight(playerId, state.lastEvidence)); + } + + void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason, + SentinelSettings settings) { + var state = players.computeIfAbsent(playerId, ignored -> new State()); + state.expire(nowNanos); + state.resetSequence(); + state.currentExclusion = reason; + state.graceThroughTick = Math.max(state.graceThroughTick, + currentTick + settings.exemptionGraceTicks()); + } + + Optional inspect(UUID playerId, long nowNanos) { + var state = players.get(playerId); + if (state == null) return Optional.empty(); + state.expire(nowNanos); + if (state.lastEvidence == null) return Optional.empty(); + return Optional.of(state.evidence(nowNanos)); + } + + void cleanup(int currentTick, long nowNanos) { + players.entrySet().removeIf(entry -> { + var state = entry.getValue(); + state.expire(nowNanos); + if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence(); + return state.world == null && state.lastEvidence == null + && state.cooldownUntilNanos <= nowNanos && currentTick > state.graceThroughTick; + }); + } + + void forget(UUID playerId) { + players.remove(playerId); + } + + void clear() { + players.clear(); + } + + private static boolean ineligible(FlightFrame frame) { + return frame.authorized() || 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, FlightFrame frame, SentinelSettings settings) { + if (frame.flying()) return DetectionSignal.UNAUTHORIZED_FLIGHT_STATE; + if (state.verticalDisplacement >= settings.unsupportedAscentBlocks()) { + return DetectionSignal.UNSUPPORTED_ASCENT; + } + if (state.hoverTicks >= settings.unsupportedHoverTicks()) return DetectionSignal.UNSUPPORTED_HOVER; + return DetectionSignal.UNSUPPORTED_CRUISE; + } + + private static boolean thresholdReached(State state, SentinelSettings settings) { + return switch (state.signal) { + case UNAUTHORIZED_FLIGHT_STATE -> state.unauthorizedFlightTicks >= settings.unsupportedFlightTicks(); + case UNSUPPORTED_ASCENT -> state.ascentTicks >= 10; + case UNSUPPORTED_HOVER -> state.hoverTicks >= settings.unsupportedHoverTicks(); + case UNSUPPORTED_CRUISE -> state.cruiseTicks >= settings.unsupportedFlightTicks() + && state.horizontalDistance >= settings.unsupportedCruiseBlocks(); + case ABSOLUTE_GROUND_SPEED -> 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 world; + private int lastTick = Integer.MIN_VALUE; + private long firstObservedAtNanos; + private long evidenceExpiresAtNanos; + private long cooldownUntilNanos; + private double startY; + private int eligibleTicks; + private int unauthorizedFlightTicks; + private int ascentTicks; + private int hoverTicks; + private int cruiseTicks; + private int graceThroughTick = Integer.MIN_VALUE; + private double horizontalDistance; + private double verticalDisplacement; + private DetectionSignal signal = DetectionSignal.UNSUPPORTED_CRUISE; + private boolean authorized; + private boolean supported; + private ExclusionReason currentExclusion = ExclusionReason.NONE; + private FlightEvidence lastEvidence; + private long evidenceFirstObservedAtNanos; + + private boolean connected(FlightFrame frame) { + return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick(); + } + + private void startSequence(FlightFrame 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 = 0; + unauthorizedFlightTicks = 0; + ascentTicks = 0; + hoverTicks = 0; + cruiseTicks = 0; + horizontalDistance = 0; + verticalDisplacement = 0; + signal = DetectionSignal.UNSUPPORTED_CRUISE; + } + + private void expire(long nowNanos) { + if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null; + } + + private FlightEvidence evidence(long nowNanos) { + FlightEvidence source = lastEvidence; + int ticks = eligibleTicks; + int flyingTicks = unauthorizedFlightTicks; + int climbingTicks = ascentTicks; + int levelTicks = hoverTicks; + int travelTicks = cruiseTicks; + double horizontal = horizontalDistance; + double vertical = verticalDisplacement; + DetectionSignal currentSignal = signal; + long first = firstObservedAtNanos; + if (eligibleTicks == 0 && source != null) { + ticks = source.eligibleTicks(); + flyingTicks = source.unauthorizedFlightTicks(); + climbingTicks = source.ascentTicks(); + levelTicks = source.hoverTicks(); + travelTicks = source.cruiseTicks(); + horizontal = source.horizontalDistance(); + vertical = source.verticalDisplacement(); + currentSignal = source.signal(); + first = evidenceFirstObservedAtNanos; + } + return new FlightEvidence(currentSignal, authorized, supported, ticks, + flyingTicks, climbingTicks, levelTicks, travelTicks, 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/FlightEvidence.java b/src/main/java/games/dmg/purpursentinel/FlightEvidence.java new file mode 100644 index 0000000..b45969e --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/FlightEvidence.java @@ -0,0 +1,17 @@ +package games.dmg.purpursentinel; + +public record FlightEvidence( + DetectionSignal signal, + boolean authorized, + boolean supported, + int eligibleTicks, + int unauthorizedFlightTicks, + 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/FlightFrame.java b/src/main/java/games/dmg/purpursentinel/FlightFrame.java new file mode 100644 index 0000000..f57276b --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/FlightFrame.java @@ -0,0 +1,27 @@ +package games.dmg.purpursentinel; + +import java.util.UUID; + +public record FlightFrame( + int tick, + long observedAtNanos, + UUID world, + double fromX, + double fromY, + double fromZ, + double toX, + double toY, + double toZ, + boolean authorized, + boolean flying, + SupportState supportState, + ExclusionReason exclusionReason) { + + public static FlightFrame unsupported(int tick, long observedAtNanos, UUID world, + double fromX, double fromY, double fromZ, double toX, double toY, double toZ, + boolean authorized, boolean flying) { + return new FlightFrame(tick, observedAtNanos, world, + fromX, fromY, fromZ, toX, toY, toZ, + authorized, flying, SupportState.UNSUPPORTED, ExclusionReason.NONE); + } +} diff --git a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java index 2c4e65b..5ede968 100644 --- a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java +++ b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java @@ -6,5 +6,12 @@ public record InspectionSnapshot( double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, - double cooldownRemainingSeconds) { + double cooldownRemainingSeconds, + FlightEvidence flightEvidence) { + + public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, + ExclusionReason currentExclusion, double cooldownRemainingSeconds) { + this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, + currentExclusion, cooldownRemainingSeconds, null); + } } diff --git a/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java b/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java index 8ef885b..8c5fa94 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java +++ b/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java @@ -24,6 +24,27 @@ final class MovementEnvironment { private MovementEnvironment() { } + static SupportState supportState(Location location) { + var world = location.getWorld(); + if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { + return SupportState.UNKNOWN; + } + double minX = location.getX() - 0.3; + double maxX = location.getX() + 0.3; + double minZ = location.getZ() - 0.3; + double maxZ = location.getZ() + 0.3; + int y = (int) Math.floor(location.getY() - 0.01); + for (int x = (int) Math.floor(minX); x <= (int) Math.floor(maxX); x++) { + for (int z = (int) Math.floor(minZ); z <= (int) Math.floor(maxZ); z++) { + var block = world.getBlockAt(x, y, z); + if (block.getType() != Material.AIR && !block.isLiquid() && !block.isPassable()) { + return SupportState.SUPPORTED; + } + } + } + return 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 2ed6d0f..a8147f9 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java +++ b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java @@ -10,12 +10,18 @@ import java.util.UUID; 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 Map pending = new HashMap<>(); + private final List flightAlerts = new ArrayList<>(); public MovementMonitor(MovementDetector detector) { this.detector = detector; } + public void record(UUID playerId, FlightFrame frame, SentinelSettings settings) { + flightDetector.observe(playerId, 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()) { @@ -31,7 +37,8 @@ public final class MovementMonitor { } public List finishBefore(int currentTick, long nowNanos) { - var alerts = new ArrayList(); + var alerts = new ArrayList(flightAlerts); + flightAlerts.clear(); var iterator = pending.entrySet().iterator(); while (iterator.hasNext()) { var entry = iterator.next(); @@ -41,26 +48,41 @@ public final class MovementMonitor { } } detector.cleanup(currentTick, nowNanos); + flightDetector.cleanup(currentTick, nowNanos); return List.copyOf(alerts); } public void exempt(UUID playerId, int tick, long nowNanos, ExclusionReason reason, SentinelSettings settings) { pending.remove(playerId); detector.exempt(playerId, tick, nowNanos, reason, settings); + flightDetector.exempt(playerId, tick, nowNanos, reason, settings); } public Optional inspect(UUID playerId, long nowNanos) { - return detector.inspect(playerId, nowNanos); + var movement = detector.inspect(playerId, nowNanos); + var flight = flightDetector.inspect(playerId, nowNanos); + if (flight.isEmpty()) return movement; + if (movement.isEmpty()) { + return Optional.of(new InspectionSnapshot(0, 0, 0, 0, + ExclusionReason.NONE, 0, flight.get())); + } + var evidence = movement.get(); + return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(), + evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(), + evidence.cooldownRemainingSeconds(), flight.get())); } public void forget(UUID playerId) { pending.remove(playerId); detector.forget(playerId); + flightDetector.forget(playerId); } public void clear() { pending.clear(); detector.clear(); + flightDetector.clear(); + flightAlerts.clear(); } int trackedPlayerCount() { diff --git a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java index bd3aee5..25e0fef 100644 --- a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java +++ b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java @@ -26,8 +26,10 @@ public final class PurpurSentinelPlugin extends JavaPlugin { command.setTabCompleter(handler); getServer().getPluginManager().registerEvents(listener, this); var alerts = new SentinelAlerts(getServer()); - tickTask = getServer().getScheduler().runTaskTimer(this, () -> monitor - .finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send), 1L, 1L); + tickTask = getServer().getScheduler().runTaskTimer(this, () -> { + listener.sampleFlight(); + monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send); + }, 1L, 1L); } catch (IOException | IllegalArgumentException exception) { getLogger().severe("Sentinel configuration is invalid or unavailable; observation was not activated: " + exception.getMessage()); getServer().getPluginManager().disablePlugin(this); @@ -40,6 +42,7 @@ public final class PurpurSentinelPlugin extends JavaPlugin { tickTask = null; } if (listener != null) { + listener.clear(); HandlerList.unregisterAll(listener); listener = null; } diff --git a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java index 7ca0cbc..55f9fb9 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java @@ -13,9 +13,17 @@ final class SentinelAlerts { void send(AlertDecision alert) { var subject = server.getPlayer(alert.playerId()); if (subject == null || !subject.isOnline()) return; - String message = String.format(Locale.ROOT, - "[Sentinel] %s: %d movement breaches; peak %.2f blocks/tick vs captured limit %.2f. Use /sentinel inspect %s.", - subject.getName(), alert.breachCount(), alert.peakSpeed(), alert.capturedLimit(), subject.getName()); + String message; + if (alert.signal() == DetectionSignal.ABSOLUTE_GROUND_SPEED) { + message = String.format(Locale.ROOT, + "[Sentinel] %s: %d movement breaches; peak %.2f blocks/tick vs captured limit %.2f. Use /sentinel inspect %s.", + subject.getName(), alert.breachCount(), alert.peakSpeed(), alert.capturedLimit(), subject.getName()); + } else { + message = String.format(Locale.ROOT, + "[Sentinel] %s: %s; %d unsupported ticks; %.2f horizontal blocks; %.2f vertical blocks. Use /sentinel inspect %s.", + subject.getName(), alert.signal(), alert.eligibleTicks(), alert.horizontalDistance(), + alert.verticalDisplacement(), subject.getName()); + } server.getOnlinePlayers().stream() .filter(recipient -> recipient.hasPermission("sentinel.alerts")) .forEach(recipient -> recipient.sendMessage(message)); diff --git a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java index f38cec9..33829db 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java @@ -93,10 +93,21 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { return true; } var evidence = snapshot.get(); - sender.sendMessage(String.format(Locale.ROOT, - "%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.", - player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(), - evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds())); + 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.", + player.get().getName(), flight.signal(), yesNo(flight.authorized()), yesNo(flight.supported()), + flight.eligibleTicks(), flight.unauthorizedFlightTicks(), flight.ascentTicks(), flight.hoverTicks(), + flight.cruiseTicks(), flight.horizontalDistance(), flight.verticalDisplacement(), + flight.evidenceAgeSeconds(), flight.currentExclusion().description(), + flight.cooldownRemainingSeconds())); + } else { + sender.sendMessage(String.format(Locale.ROOT, + "%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.", + player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(), + evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds())); + } return true; } @@ -129,6 +140,10 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { return true; } + private static String yesNo(boolean value) { + return value ? "yes" : "no"; + } + private static List filter(List options, String prefix) { String normalized = prefix.toLowerCase(Locale.ROOT); return options.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized)) diff --git a/src/main/java/games/dmg/purpursentinel/SentinelSettings.java b/src/main/java/games/dmg/purpursentinel/SentinelSettings.java index de7f073..88eea39 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelSettings.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelSettings.java @@ -6,7 +6,18 @@ public record SentinelSettings( int evidenceWindowSeconds, int alertCooldownSeconds, double minimumTps, - int exemptionGraceTicks) { + int exemptionGraceTicks, + int unsupportedFlightTicks, + int unsupportedHoverTicks, + double unsupportedAscentBlocks, + double unsupportedCruiseBlocks) { + + 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); + } + public SentinelSettings { requireFiniteRange(horizontalSpeedLimit, 0.40, 5.00, "horizontal-speed-limit"); requireRange(breachesToAlert, 3, 40, "breaches-to-alert"); @@ -14,10 +25,14 @@ public record SentinelSettings( requireRange(alertCooldownSeconds, 5, 3600, "alert-cooldown-seconds"); requireFiniteRange(minimumTps, 10.0, 20.0, "minimum-tps"); requireRange(exemptionGraceTicks, 1, 200, "exemption-grace-ticks"); + requireRange(unsupportedFlightTicks, 10, 200, "unsupported-flight-ticks"); + requireRange(unsupportedHoverTicks, 10, 200, "unsupported-hover-ticks"); + requireFiniteRange(unsupportedAscentBlocks, 0.5, 20.0, "unsupported-ascent-blocks"); + requireFiniteRange(unsupportedCruiseBlocks, 2.0, 100.0, "unsupported-cruise-blocks"); } public static SentinelSettings defaults() { - return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20); + return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.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 02dae0d..11c6ee4 100644 --- a/src/main/java/games/dmg/purpursentinel/SettingKey.java +++ b/src/main/java/games/dmg/purpursentinel/SettingKey.java @@ -10,7 +10,11 @@ public enum SettingKey { EVIDENCE_WINDOW_SECONDS("evidence-window-seconds", "2", "60", true), ALERT_COOLDOWN_SECONDS("alert-cooldown-seconds", "5", "3600", true), MINIMUM_TPS("minimum-tps", "10.0", "20.0", false), - EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true); + EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true), + UNSUPPORTED_FLIGHT_TICKS("unsupported-flight-ticks", "10", "200", true), + UNSUPPORTED_HOVER_TICKS("unsupported-hover-ticks", "10", "200", true), + UNSUPPORTED_ASCENT_BLOCKS("unsupported-ascent-blocks", "0.5", "20.0", false), + UNSUPPORTED_CRUISE_BLOCKS("unsupported-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 bcffb25..b775c45 100644 --- a/src/main/java/games/dmg/purpursentinel/SettingsService.java +++ b/src/main/java/games/dmg/purpursentinel/SettingsService.java @@ -43,7 +43,11 @@ public final class SettingsService { integer(values, SettingKey.EVIDENCE_WINDOW_SECONDS), integer(values, SettingKey.ALERT_COOLDOWN_SECONDS), decimal(values, SettingKey.MINIMUM_TPS), - integer(values, SettingKey.EXEMPTION_GRACE_TICKS)); + integer(values, SettingKey.EXEMPTION_GRACE_TICKS), + optionalInteger(values, SettingKey.UNSUPPORTED_FLIGHT_TICKS, 40), + optionalInteger(values, SettingKey.UNSUPPORTED_HOVER_TICKS, 30), + optionalDecimal(values, SettingKey.UNSUPPORTED_ASCENT_BLOCKS, 2.0), + optionalDecimal(values, SettingKey.UNSUPPORTED_CRUISE_BLOCKS, 10.0)); } private static Object parseValue(SettingKey key, String value) { @@ -60,6 +64,14 @@ public final class SettingsService { } } + private static int optionalInteger(Map values, SettingKey key, int defaultValue) { + return values.containsKey(key.key()) ? integer(values, key) : defaultValue; + } + + private static double optionalDecimal(Map values, SettingKey key, double defaultValue) { + return values.containsKey(key.key()) ? decimal(values, key) : defaultValue; + } + private static int integer(Map values, SettingKey key) { Object value = required(values, key); if (!(value instanceof Number number)) throw new IllegalArgumentException(key.key() + " must be an integer."); @@ -92,6 +104,10 @@ public final class SettingsService { case ALERT_COOLDOWN_SECONDS -> Integer.toString(settings.alertCooldownSeconds()); case MINIMUM_TPS -> decimal(settings.minimumTps(), 1); case EXEMPTION_GRACE_TICKS -> Integer.toString(settings.exemptionGraceTicks()); + case UNSUPPORTED_FLIGHT_TICKS -> Integer.toString(settings.unsupportedFlightTicks()); + case UNSUPPORTED_HOVER_TICKS -> Integer.toString(settings.unsupportedHoverTicks()); + case UNSUPPORTED_ASCENT_BLOCKS -> decimal(settings.unsupportedAscentBlocks(), 1); + case UNSUPPORTED_CRUISE_BLOCKS -> decimal(settings.unsupportedCruiseBlocks(), 1); }; } diff --git a/src/main/java/games/dmg/purpursentinel/SupportState.java b/src/main/java/games/dmg/purpursentinel/SupportState.java new file mode 100644 index 0000000..e12e2df --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/SupportState.java @@ -0,0 +1,7 @@ +package games.dmg.purpursentinel; + +public enum SupportState { + SUPPORTED, + UNSUPPORTED, + UNKNOWN +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 194d6fe..fe9d66c 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -4,3 +4,7 @@ evidence-window-seconds: 10 alert-cooldown-seconds: 60 minimum-tps: 18.0 exemption-grace-ticks: 20 +unsupported-flight-ticks: 40 +unsupported-hover-ticks: 30 +unsupported-ascent-blocks: 2.0 +unsupported-cruise-blocks: 10.0 diff --git a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java index b0ee990..fc84885 100644 --- a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java @@ -90,8 +90,10 @@ class PluginLifecycleTest { when(subject.isOnline()).thenReturn(true); when(subject.getGameMode()).thenReturn(GameMode.SURVIVAL); when(subject.getPose()).thenReturn(Pose.STANDING); + when(subject.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(operator.hasPermission("sentinel.alerts")).thenReturn(true); when(operator.hasPermission("sentinel.inspect")).thenReturn(true); + when(operator.hasPermission("sentinel.config")).thenReturn(true); when(server.getPlayer(subject.getUniqueId())).thenReturn(subject); doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers(); @@ -114,6 +116,8 @@ class PluginLifecycleTest { assertTrue(command.execute(operator, "sentinel", new String[]{"inspect", "Alex"})); verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: 6 breaches"))); assertEquals(List.of("inspect"), command.tabComplete(operator, "sentinel", new String[]{"in"})); + assertEquals(List.of("unsupported-flight-ticks"), + command.tabComplete(operator, "sentinel", new String[]{"config", "unsupported-f"})); Field monitorField = PurpurSentinelPlugin.class.getDeclaredField("monitor"); monitorField.setAccessible(true); diff --git a/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java b/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java index 06570bd..7a1e46e 100644 --- a/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java +++ b/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java @@ -40,6 +40,58 @@ class BukkitMovementListenerTest { assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty(), name); } + @Test void unsupportedSurvivalMovementFeedsFlightEvidenceWithoutTrustingFlyingAsAuthorization() { + var fixture = fixture(); + when(fixture.player.isFlying()).thenReturn(true); + when(fixture.player.getAllowFlight()).thenReturn(false); + doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers(); + for (int tick = 1; tick <= 40; tick++) { + when(fixture.server.getCurrentTick()).thenReturn(tick); + when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, tick * 0.1, 80, 0)); + fixture.listener.sampleFlight(); + } + var alerts = fixture.monitor.finishBefore(41, 2_050_000_000L); + assertEquals(1, alerts.size()); + assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal()); + } + + @Test void serverGrantedSurvivalFlightIsAuthorized() { + var fixture = fixture(); + when(fixture.player.isFlying()).thenReturn(true); + when(fixture.player.getAllowFlight()).thenReturn(true); + when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 2, 80, 0)); + doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers(); + fixture.listener.sampleFlight(); + assertTrue(fixture.monitor.finishBefore(2, 100_000_000L).isEmpty()); + assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty()); + } + + @Test void solidServerGeometryStopsUnsupportedFlightEvidence() { + var fixture = fixture(SupportState.SUPPORTED); + when(fixture.player.isFlying()).thenReturn(true); + when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 0, 80, 0)); + doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers(); + for (int tick = 1; tick <= 50; tick++) { + when(fixture.server.getCurrentTick()).thenReturn(tick); + fixture.listener.sampleFlight(); + } + assertTrue(fixture.monitor.finishBefore(51, 2_550_000_000L).isEmpty()); + assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 2_550_000_000L).isEmpty()); + } + + @Test void unknownGeometryIsExcludedInsteadOfTreatedAsUnsupported() { + var fixture = fixture(SupportState.UNKNOWN); + when(fixture.player.isFlying()).thenReturn(true); + when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 0, 80, 0)); + doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers(); + for (int tick = 1; tick <= 50; tick++) { + when(fixture.server.getCurrentTick()).thenReturn(tick); + fixture.listener.sampleFlight(); + } + assertTrue(fixture.monitor.finishBefore(51, 2_550_000_000L).isEmpty()); + assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 2_550_000_000L).isEmpty()); + } + @Test void lifecycleVelocityQuitAndCancelledMovementArePassiveExemptions() { var fixture = fixture(); fixture.listener.onMove(move(fixture.player, fixture.world, 0, 2, true)); @@ -55,7 +107,7 @@ class BukkitMovementListenerTest { return Stream.of( Arguments.of("creative", (java.util.function.Consumer) f -> when(f.player.getGameMode()).thenReturn(GameMode.CREATIVE)), Arguments.of("vehicle", (java.util.function.Consumer) f -> when(f.player.isInsideVehicle()).thenReturn(true)), - Arguments.of("flying", (java.util.function.Consumer) f -> when(f.player.isFlying()).thenReturn(true)), + Arguments.of("authorized flight", (java.util.function.Consumer) f -> when(f.player.getAllowFlight()).thenReturn(true)), Arguments.of("gliding", (java.util.function.Consumer) f -> when(f.player.isGliding()).thenReturn(true)), Arguments.of("swimming", (java.util.function.Consumer) f -> when(f.player.isSwimming()).thenReturn(true)), Arguments.of("crawling", (java.util.function.Consumer) f -> when(f.player.getPose()).thenReturn(Pose.SWIMMING)), @@ -65,6 +117,10 @@ class BukkitMovementListenerTest { } private static Fixture fixture() { + return fixture(SupportState.UNSUPPORTED); + } + + private static Fixture fixture(SupportState support) { var server = mock(Server.class); when(server.getCurrentTick()).thenReturn(1); when(server.getTPS()).thenReturn(new double[]{20, 20, 20}); @@ -74,6 +130,7 @@ class BukkitMovementListenerTest { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mock(org.bukkit.block.Block.class, invocation -> { if (invocation.getMethod().getName().equals("getType")) return org.bukkit.Material.AIR; if (invocation.getMethod().getName().equals("isLiquid")) return false; + if (invocation.getMethod().getName().equals("isPassable")) return true; return RETURNS_DEFAULTS.answer(invocation); })); var player = mock(Player.class); @@ -82,7 +139,8 @@ class BukkitMovementListenerTest { when(player.getPose()).thenReturn(Pose.STANDING); var monitor = new MovementMonitor(new MovementDetector()); var clock = new AtomicLong(50_000_000L); - var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get, (from, to) -> true); + var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get, + (from, to) -> true, location -> support); return new Fixture(server, world, player, monitor, listener); } diff --git a/src/test/java/games/dmg/purpursentinel/DistributionTest.java b/src/test/java/games/dmg/purpursentinel/DistributionTest.java index 5d9e42c..26df0a5 100644 --- a/src/test/java/games/dmg/purpursentinel/DistributionTest.java +++ b/src/test/java/games/dmg/purpursentinel/DistributionTest.java @@ -27,8 +27,12 @@ class DistributionTest { assertNull(metadata.get("depend")); assertNull(metadata.get("softdepend")); assertNotNull(jar.getEntry("config.yml")); - assertTrue(new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes()) - .contains("horizontal-speed-limit: 0.80")); + String config = new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes()); + assertTrue(config.contains("horizontal-speed-limit: 0.80")); + assertTrue(config.contains("unsupported-flight-ticks: 40")); + assertTrue(config.contains("unsupported-hover-ticks: 30")); + assertTrue(config.contains("unsupported-ascent-blocks: 2.0")); + assertTrue(config.contains("unsupported-cruise-blocks: 10.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/FlightMonitorTest.java b/src/test/java/games/dmg/purpursentinel/FlightMonitorTest.java new file mode 100644 index 0000000..998b983 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/FlightMonitorTest.java @@ -0,0 +1,218 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class FlightMonitorTest { + private static final SentinelSettings SETTINGS = SentinelSettings.defaults(); + + @Test void activeFlightSequenceSurvivesPerTickCleanupUntilItAlerts() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + java.util.List alerts = java.util.List.of(); + for (int tick = 1; tick <= 40; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.3, 80, 0, false, true), SETTINGS); + alerts = monitor.finishBefore(tick + 1, tick * 50_000_000L + 1); + } + assertEquals(1, alerts.size()); + assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal()); + } + + @Test void sustainedUnauthorizedFlyingStateAlertsWithExplainableEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + + for (int tick = 1; tick <= 40; tick++) { + monitor.record(player, FlightFrame.unsupported( + tick, tick * 50_000_000L, world, + 0, 80, 0, 0.1, 80, 0, + false, true), SETTINGS); + } + + var alerts = monitor.finishBefore(41, 2_050_000_000L); + assertEquals(1, alerts.size()); + assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal()); + assertEquals(40, alerts.getFirst().eligibleTicks()); + assertEquals(4.0, alerts.getFirst().horizontalDistance(), 1e-9); + assertEquals(0.0, alerts.getFirst().verticalDisplacement(), 1e-9); + + var evidence = monitor.inspect(player, 2_050_000_000L).orElseThrow().flightEvidence(); + assertFalse(evidence.authorized()); + assertFalse(evidence.supported()); + assertEquals(40, evidence.eligibleTicks()); + assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, evidence.signal()); + } + + @Test void sustainedAscentHoverAndCruiseProduceDistinctSignals() { + assertEquals(DetectionSignal.UNSUPPORTED_ASCENT, + alertFor(40, 0, 0.06, false, SETTINGS).signal()); + assertEquals(DetectionSignal.UNSUPPORTED_HOVER, + alertFor(30, 0.01, 0, false, SETTINGS).signal()); + assertEquals(DetectionSignal.UNSUPPORTED_CRUISE, + alertFor(40, 0.3, 0, false, SETTINGS).signal()); + } + + @Test void ordinaryJumpAndFallTrajectoryNeverAlerts() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + double y = 64; + double verticalVelocity = 0.42; + for (int tick = 1; tick <= 18; tick++) { + double nextY = y + verticalVelocity; + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, y, 0, 0.1, nextY, 0, false, false), SETTINGS); + y = nextY; + verticalVelocity = (verticalVelocity - 0.08) * 0.98; + } + assertTrue(monitor.finishBefore(19, 950_000_000L).isEmpty()); + } + + @Test void exclusionStartsGraceAndEvidenceExpiresWithoutMoreMovement() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + monitor.record(player, new FlightFrame(1, 50_000_000L, world, + 0, 80, 0, 0, 80, 0, false, false, + SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS); + for (int tick = 2; tick <= 21; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.4, 80, 0, false, false), SETTINGS); + } + assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty()); + assertTrue(monitor.inspect(player, 1_100_000_000L).isEmpty()); + + for (int tick = 22; tick <= 61; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.4, 80, 0, false, false), SETTINGS); + } + assertEquals(DetectionSignal.UNSUPPORTED_CRUISE, + monitor.finishBefore(62, 3_100_000_000L).getFirst().signal()); + assertTrue(monitor.inspect(player, 3_100_000_000L).isPresent()); + monitor.finishBefore(300, 20_000_000_000L); + assertTrue(monitor.inspect(player, 20_000_000_000L).isEmpty()); + } + + @Test void disconnectAndDisableClearFlightEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + for (int tick = 1; tick <= 40; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.5, 80, 0, false, true), SETTINGS); + } + assertTrue(monitor.inspect(player, 2_000_000_000L).isPresent()); + monitor.forget(player); + assertTrue(monitor.inspect(player, 50_000_000L).isEmpty()); + for (int tick = 41; tick <= 80; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.5, 80, 0, false, true), SETTINGS); + } + monitor.clear(); + assertTrue(monitor.inspect(player, 4_000_000_000L).isEmpty()); + } + + @Test void externalLifecycleExemptionStartsFlightGrace() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + monitor.exempt(player, 1, 50_000_000L, ExclusionReason.VELOCITY, SETTINGS); + for (int tick = 2; tick <= 21; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 1, 80, 0, false, true), SETTINGS); + } + assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty()); + assertTrue(monitor.inspect(player, 1_100_000_000L).isEmpty()); + } + + @Test void everyApprovedExclusionAndUnknownSupportResetsFlightEvidenceWithGrace() { + for (ExclusionReason reason : java.util.List.of( + ExclusionReason.GAME_MODE, ExclusionReason.VEHICLE, ExclusionReason.FLYING, + ExclusionReason.GLIDING, ExclusionReason.SWIMMING, ExclusionReason.CLIMBING, + ExclusionReason.RIPTIDE, ExclusionReason.POTION_EFFECT, ExclusionReason.ENVIRONMENT, + ExclusionReason.LOW_TPS, ExclusionReason.LIFECYCLE, ExclusionReason.VELOCITY, + ExclusionReason.DAMAGE)) { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + monitor.record(player, new FlightFrame(1, 50_000_000L, world, + 0, 80, 0, 1, 80, 0, false, false, + SupportState.UNSUPPORTED, reason), SETTINGS); + for (int tick = 2; tick <= 21; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 1, 80, 0, false, true), SETTINGS); + } + assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty(), reason.toString()); + } + } + + @Test void duplicateAndDisconnectedTicksCannotInflateOrPreserveFlightSequence() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + for (int tick = 1; tick <= 39; tick++) { + var frame = FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.5, 80, 0, false, true); + monitor.record(player, frame, SETTINGS); + monitor.record(player, frame, SETTINGS); + } + assertTrue(monitor.finishBefore(40, 2_000_000_000L).isEmpty()); + monitor.record(player, FlightFrame.unsupported(41, 2_050_000_000L, world, + 0, 80, 0, 0.5, 80, 0, false, true), SETTINGS); + assertTrue(monitor.finishBefore(42, 2_100_000_000L).isEmpty()); + } + + @Test void changingAuthorizationClearsPriorUnsupportedSequence() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + for (int tick = 1; tick <= 39; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.5, 80, 0, false, true), SETTINGS); + } + monitor.record(player, FlightFrame.unsupported(40, 2_000_000_000L, world, + 0, 80, 0, 0.5, 80, 0, true, true), SETTINGS); + for (int tick = 41; tick <= 79; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + 0, 80, 0, 0.5, 80, 0, false, true), SETTINGS); + } + assertTrue(monitor.finishBefore(80, 4_000_000_000L).isEmpty()); + } + + @Test void survivalFlightGrantedByTheServerIsAuthorizedAndNeverAlerts() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + + for (int tick = 1; tick <= 50; tick++) { + monitor.record(player, FlightFrame.unsupported( + tick, tick * 50_000_000L, world, + 0, 80, 0, 0.2, 80.1, 0, + true, true), SETTINGS); + } + + assertTrue(monitor.finishBefore(51, 2_550_000_000L).isEmpty()); + assertTrue(monitor.inspect(player, 2_550_000_000L).isEmpty()); + } + + private static AlertDecision alertFor(int ticks, double horizontalPerTick, double verticalPerTick, + boolean flying, SentinelSettings settings) { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var world = UUID.randomUUID(); + double x = 0; + double y = 80; + for (int tick = 1; tick <= ticks; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world, + x, y, 0, x + horizontalPerTick, y + verticalPerTick, 0, false, flying), settings); + x += horizontalPerTick; + y += verticalPerTick; + } + return monitor.finishBefore(ticks + 1, (ticks + 1) * 50_000_000L).getFirst(); + } +} diff --git a/src/test/java/games/dmg/purpursentinel/PrivacyBoundaryTest.java b/src/test/java/games/dmg/purpursentinel/PrivacyBoundaryTest.java index c791190..b4baefc 100644 --- a/src/test/java/games/dmg/purpursentinel/PrivacyBoundaryTest.java +++ b/src/test/java/games/dmg/purpursentinel/PrivacyBoundaryTest.java @@ -25,6 +25,13 @@ class PrivacyBoundaryTest { monitor.finishBefore(tick + 1, tick * 50_000_000L + 1); } assertTrue(monitor.inspect(player, 400_000_000L).isPresent()); + var flightWorld = UUID.randomUUID(); + for (int tick = 10; tick < 50; tick++) { + monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, flightWorld, + 120.25, 90, -45.5, 120.55, 90, -45.5, false, true), service.current()); + } + monitor.finishBefore(50, 2_500_000_000L); + assertNotNull(monitor.inspect(player, 2_500_000_000L).orElseThrow().flightEvidence()); assertArrayEquals(before, Files.readAllBytes(file)); assertEquals(java.util.Set.of("config.yml"), Files.list(directory) .map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet())); @@ -32,5 +39,6 @@ class PrivacyBoundaryTest { assertFalse(disk.contains(player.toString())); assertFalse(disk.contains("120.25")); assertFalse(disk.contains("-45.5")); + assertFalse(disk.contains("unsupported ticks")); } } diff --git a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java index e339c27..7b4374e 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java @@ -29,4 +29,25 @@ class SentinelAlertsTest { verify(denied, never()).sendMessage(anyString()); verify(server, never()).getConsoleSender(); } + + @Test @SuppressWarnings({"unchecked", "rawtypes"}) + void flightAlertNamesSignalAndBoundedDisplacementWithoutAccusingOrReportingCoordinates() { + 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(); + + new SentinelAlerts(server).send(new AlertDecision(id, DetectionSignal.UNSUPPORTED_ASCENT, + 0, 0, 0, 40, 8.5, 2.4)); + + verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_ASCENT") + && message.contains("40 unsupported ticks") && message.contains("8.50 horizontal") + && message.contains("2.40 vertical") && !message.toLowerCase().contains("cheat") + && !message.contains("x=") && !message.contains("z="))); + } } diff --git a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java index f5e74c0..abb80f0 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java @@ -52,6 +52,30 @@ class SentinelCommandTest { verify(online, never()).teleport(any(org.bukkit.Location.class)); } + @Test void inspectReportsBoundedFlightStateSignalsAgeAndCooldown() throws Exception { + var fixture = fixture(); + var target = online(fixture.server, "Flyer"); + var sender = permitted("sentinel.inspect"); + var world = UUID.randomUUID(); + double y = 80; + for (int tick = 1; tick <= 40; tick++) { + fixture.monitor.record(target.getUniqueId(), FlightFrame.unsupported( + tick, tick * 50_000_000L, world, + 0, y, 0, 0, y + 0.06, 0, false, false), fixture.settings.current()); + y += 0.06; + } + fixture.monitor.finishBefore(41, 2_050_000_000L); + + assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel", + new String[]{"inspect", "Flyer"})); + verify(sender).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_ASCENT") + && message.contains("authorized no") && message.contains("supported no") + && message.contains("40 eligible ticks") + && message.contains("signal counts flying 0, ascent 40, hover 0, cruise 0") + && message.contains("ascent 2.40") && message.contains("cooldown") + && !message.contains("80"))); + } + @Test void configReadsAndDurablyUpdatesBeforeFutureDetection() throws Exception { var fixture = fixture(); var sender = permitted("sentinel.config"); diff --git a/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java b/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java index e7f0bf2..cb63eba 100644 --- a/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java +++ b/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java @@ -14,7 +14,8 @@ class SettingsServiceTest { @Test void validUpdatePersistsBeforePublicationAndPreservesUnrelatedYaml() throws Exception { var file = directory.resolve("config.yml"); - Files.writeString(file, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\nevidence-window-seconds: 10\nalert-cooldown-seconds: 60\nminimum-tps: 18.0\nexemption-grace-ticks: 20\nunrelated: keep-me\n"); + AtomicYamlSettingsStore.writeDefaults(file); + Files.writeString(file, Files.readString(file) + "unrelated: keep-me\n"); var service = SettingsService.load(new AtomicYamlSettingsStore(file)); assertEquals("0.60", service.update(SettingKey.HORIZONTAL_SPEED_LIMIT, "0.60")); @@ -56,6 +57,40 @@ class SettingsServiceTest { assertEquals(service.current(), loaded.current()); } + @Test void existingVersionOneConfigurationReceivesFlightDefaultsWithoutRewrite() throws Exception { + var file = directory.resolve("config.yml"); + Files.writeString(file, "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\nunrelated: keep-me\n"); + byte[] before = Files.readAllBytes(file); + var service = SettingsService.load(new AtomicYamlSettingsStore(file)); + assertEquals(40, service.current().unsupportedFlightTicks()); + assertEquals(30, service.current().unsupportedHoverTicks()); + assertEquals(2.0, service.current().unsupportedAscentBlocks(), 1e-9); + assertEquals(10.0, service.current().unsupportedCruiseBlocks(), 1e-9); + assertArrayEquals(before, Files.readAllBytes(file)); + } + + @Test void flightThresholdsLoadPersistAndRejectOutOfRangeValues() throws Exception { + var file = directory.resolve("config.yml"); + AtomicYamlSettingsStore.writeDefaults(file); + var service = SettingsService.load(new AtomicYamlSettingsStore(file)); + assertEquals(40, service.current().unsupportedFlightTicks()); + assertEquals(30, service.current().unsupportedHoverTicks()); + assertEquals(2.0, service.current().unsupportedAscentBlocks(), 1e-9); + assertEquals(10.0, service.current().unsupportedCruiseBlocks(), 1e-9); + + assertEquals("50", service.update(SettingKey.UNSUPPORTED_FLIGHT_TICKS, "50")); + assertEquals("3.5", service.update(SettingKey.UNSUPPORTED_ASCENT_BLOCKS, "3.5")); + var loaded = SettingsService.load(new AtomicYamlSettingsStore(file)); + assertEquals(50, loaded.current().unsupportedFlightTicks()); + assertEquals(3.5, loaded.current().unsupportedAscentBlocks(), 1e-9); + assertThrows(IllegalArgumentException.class, + () -> service.update(SettingKey.UNSUPPORTED_HOVER_TICKS, "9")); + assertThrows(IllegalArgumentException.class, + () -> service.update(SettingKey.UNSUPPORTED_CRUISE_BLOCKS, "101")); + } + @Test void malformedStartupConfigurationFailsBeforeUse() throws Exception { var file = directory.resolve("config.yml"); AtomicYamlSettingsStore.writeDefaults(file);