diff --git a/README.md b/README.md index 322405e..43213f4 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 and sustained unauthorized unsupported flight. 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, 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. ## Development diff --git a/src/main/java/games/dmg/purpursentinel/AlertDecision.java b/src/main/java/games/dmg/purpursentinel/AlertDecision.java index 62c05ba..19fe390 100644 --- a/src/main/java/games/dmg/purpursentinel/AlertDecision.java +++ b/src/main/java/games/dmg/purpursentinel/AlertDecision.java @@ -8,16 +8,38 @@ public record AlertDecision( int breachCount, double peakSpeed, double capturedLimit, + double ratio, + int evidenceWindowSeconds, 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); + this(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, peakSpeed, capturedLimit, + peakSpeed / capturedLimit, 0, 0, 0, 0); + } + + 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); + } + + 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); + } + + 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); } public static AlertDecision flight(UUID playerId, FlightEvidence evidence) { - return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, evidence.eligibleTicks(), + return new AlertDecision(playerId, evidence.signal(), 0, 0, 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 9e4b79b..365b2b6 100644 --- a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java +++ b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java @@ -33,6 +33,8 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence { unsupported-hover-ticks: 30 unsupported-ascent-blocks: 2.0 unsupported-cruise-blocks: 10.0 + contextual-speed-ratio: 1.35 + contextual-minimum-excess: 0.10 """, StandardOpenOption.CREATE_NEW); } } diff --git a/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java b/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java index e389612..2e69c22 100644 --- a/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java +++ b/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java @@ -10,6 +10,7 @@ import java.util.function.LongSupplier; import java.util.function.Supplier; import org.bukkit.GameMode; import org.bukkit.Location; +import org.bukkit.attribute.Attribute; import org.bukkit.entity.Pose; import org.bukkit.Server; import org.bukkit.entity.Player; @@ -33,6 +34,7 @@ public final class BukkitMovementListener implements Listener { private final LongSupplier nanoTime; private final BiPredicate ordinaryEnvironment; private final Function supportState; + private final Function groundMovementContext; private final Map lastFlightSamples = new HashMap<>(); public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime) { @@ -43,18 +45,28 @@ public final class BukkitMovementListener implements Listener { BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime, BiPredicate ordinaryEnvironment) { - this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState); + this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState, + BukkitMovementListener::groundContext); } BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime, BiPredicate ordinaryEnvironment, Function supportState) { + this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState, + BukkitMovementListener::groundContext); + } + + BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime, BiPredicate ordinaryEnvironment, + Function supportState, + Function groundMovementContext) { this.server = server; this.monitor = monitor; this.settings = settings; this.nanoTime = nanoTime; this.ordinaryEnvironment = ordinaryEnvironment; this.supportState = supportState; + this.groundMovementContext = groundMovementContext; } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) @@ -69,7 +81,8 @@ public final class BukkitMovementListener implements Listener { nanoTime.getAsLong(), Objects.requireNonNull(from.getWorld()).getUID(), Objects.requireNonNull(to.getWorld()).getUID(), - from.getX(), from.getZ(), to.getX(), to.getZ(), reason), settings.get()); + from.getX(), from.getZ(), to.getX(), to.getZ(), reason, + reason == ExclusionReason.NONE ? groundMovementContext.apply(player) : null), settings.get()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) @@ -145,14 +158,32 @@ public final class BukkitMovementListener implements Listener { 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") - || key.equals("jump_boost") || key.equals("levitation") || key.equals("slow_falling"))) { + .anyMatch(key -> 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; } + static GroundMovementContext groundContext(Player player) { + var movement = player.getAttribute(Attribute.MOVEMENT_SPEED); + if (movement == null || !Double.isFinite(movement.getValue()) || movement.getValue() <= 0) return null; + var sneaking = player.getAttribute(Attribute.SNEAKING_SPEED); + double sneakingMultiplier = sneaking == null ? 0.3 : sneaking.getValue(); + if (!Double.isFinite(sneakingMultiplier) || sneakingMultiplier <= 0) return null; + int speed = effectAmplifier(player, "speed"); + int slowness = effectAmplifier(player, "slowness"); + return new GroundMovementContext(movement.getValue(), sneakingMultiplier, player.isSprinting(), + player.isSneaking(), player.isOnGround(), speed, slowness); + } + + private static int effectAmplifier(Player player, String key) { + return player.getActivePotionEffects().stream() + .filter(effect -> effect.getType().getKey().getKey().equals(key)) + .mapToInt(org.bukkit.potion.PotionEffect::getAmplifier) + .max().orElse(-1); + } + private static boolean authorizedFlight(Player player) { return player.getGameMode() == GameMode.CREATIVE || player.getGameMode() == GameMode.SPECTATOR diff --git a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java index 599a9bf..bf876db 100644 --- a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java +++ b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java @@ -2,6 +2,7 @@ package games.dmg.purpursentinel; public enum DetectionSignal { ABSOLUTE_GROUND_SPEED, + CONTEXTUAL_GROUND_SPEED, UNAUTHORIZED_FLIGHT_STATE, UNSUPPORTED_ASCENT, UNSUPPORTED_HOVER, diff --git a/src/main/java/games/dmg/purpursentinel/FlightDetector.java b/src/main/java/games/dmg/purpursentinel/FlightDetector.java index b07d693..889ac98 100644 --- a/src/main/java/games/dmg/purpursentinel/FlightDetector.java +++ b/src/main/java/games/dmg/purpursentinel/FlightDetector.java @@ -106,7 +106,7 @@ 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 -> false; + case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED -> false; }; } diff --git a/src/main/java/games/dmg/purpursentinel/GroundMovementContext.java b/src/main/java/games/dmg/purpursentinel/GroundMovementContext.java new file mode 100644 index 0000000..4d7d212 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/GroundMovementContext.java @@ -0,0 +1,58 @@ +package games.dmg.purpursentinel; + +public record GroundMovementContext( + double movementSpeedAttribute, + double sneakingMultiplier, + boolean sprinting, + boolean sneaking, + boolean grounded, + int speedAmplifier, + int slownessAmplifier) { + private static final double ATTRIBUTE_TO_BLOCKS_PER_TICK = 2.2; + private static final double SPRINT_MULTIPLIER = 1.3; + private static final double AIRBORNE_MOMENTUM_MULTIPLIER = 1.3; + + public GroundMovementContext { + if (!Double.isFinite(movementSpeedAttribute) || movementSpeedAttribute <= 0) { + throw new IllegalArgumentException("movement speed attribute must be positive and finite"); + } + if (!Double.isFinite(sneakingMultiplier) || sneakingMultiplier <= 0) { + throw new IllegalArgumentException("sneaking multiplier must be positive and finite"); + } + if (speedAmplifier < -1 || slownessAmplifier < -1) { + throw new IllegalArgumentException("effect amplifiers must be -1 or greater"); + } + } + + public static GroundMovementContext walking(double movementSpeedAttribute) { + return withEffects(movementSpeedAttribute, false, false, true, -1, -1); + } + + public static GroundMovementContext sprinting(double baseMovementSpeedAttribute, boolean grounded) { + return withEffects(baseMovementSpeedAttribute, true, false, grounded, -1, -1); + } + + public static GroundMovementContext sneaking(double movementSpeedAttribute) { + return new GroundMovementContext(movementSpeedAttribute, 0.3, false, true, true, -1, -1); + } + + public static GroundMovementContext withEffects(double baseMovementSpeedAttribute, boolean sprinting, + boolean sneaking, boolean grounded, int speedAmplifier, int slownessAmplifier) { + double effectiveAttribute = baseMovementSpeedAttribute; + if (sprinting) effectiveAttribute *= SPRINT_MULTIPLIER; + if (speedAmplifier >= 0) effectiveAttribute *= 1.0 + 0.20 * (speedAmplifier + 1); + if (slownessAmplifier >= 0) { + effectiveAttribute *= Math.max(0.10, 1.0 - 0.15 * (slownessAmplifier + 1)); + } + return new GroundMovementContext(effectiveAttribute, 0.3, sprinting, sneaking, grounded, + speedAmplifier, slownessAmplifier); + } + + public double expectedSpeed() { + // Bukkit's effective MOVEMENT_SPEED value already includes sprint and potion modifiers. + double expected = movementSpeedAttribute * ATTRIBUTE_TO_BLOCKS_PER_TICK; + if (sneaking) expected *= sneakingMultiplier; + if (!grounded) expected *= AIRBORNE_MOMENTUM_MULTIPLIER; + return expected; + } +} diff --git a/src/main/java/games/dmg/purpursentinel/GroundSpeedEvidence.java b/src/main/java/games/dmg/purpursentinel/GroundSpeedEvidence.java new file mode 100644 index 0000000..43d24ca --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/GroundSpeedEvidence.java @@ -0,0 +1,12 @@ +package games.dmg.purpursentinel; + +public record GroundSpeedEvidence( + DetectionSignal signal, + int breachCount, + double evidenceAgeSeconds, + double observedSpeed, + double expectedLimit, + double ratio, + ExclusionReason currentExclusion, + double cooldownRemainingSeconds) { +} diff --git a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java index 5ede968..c828dd6 100644 --- a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java +++ b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java @@ -7,11 +7,18 @@ public record InspectionSnapshot( double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds, - FlightEvidence flightEvidence) { + FlightEvidence flightEvidence, + GroundSpeedEvidence contextualEvidence) { public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds) { this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, - currentExclusion, cooldownRemainingSeconds, null); + currentExclusion, cooldownRemainingSeconds, 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); } } diff --git a/src/main/java/games/dmg/purpursentinel/MovementDetector.java b/src/main/java/games/dmg/purpursentinel/MovementDetector.java index c2f7e7e..ff9226a 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementDetector.java +++ b/src/main/java/games/dmg/purpursentinel/MovementDetector.java @@ -21,27 +21,36 @@ public final class MovementDetector { state.graceThroughTick = Math.max(state.graceThroughTick, sample.tick() + settings.exemptionGraceTicks()); return Optional.empty(); } - if (sample.tick() <= state.graceThroughTick) { - return Optional.empty(); - } + if (sample.tick() <= state.graceThroughTick) return Optional.empty(); state.currentExclusion = ExclusionReason.NONE; - if (sample.horizontalDistance() <= settings.horizontalSpeedLimit()) { - return Optional.empty(); - } - if (state.evidence.size() == MAX_EVIDENCE_PER_PLAYER) { - state.evidence.removeFirst(); - } - state.evidence.addLast(new Breach( - sample.observedAtNanos(), - sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()), - sample.horizontalDistance(), - settings.horizontalSpeedLimit())); - if (state.evidence.size() < settings.breachesToAlert() || sample.observedAtNanos() < state.cooldownUntilNanos) { + + boolean absolute = sample.horizontalDistance() > settings.horizontalSpeedLimit(); + double expectedLimit = sample.groundContext() == null ? Double.NaN : sample.groundContext().expectedSpeed(); + boolean contextual = Double.isFinite(expectedLimit) + && sample.horizontalDistance() > expectedLimit * settings.contextualSpeedRatio() + && sample.horizontalDistance() - expectedLimit > settings.contextualMinimumExcess(); + if (!absolute && !contextual) return Optional.empty(); + + var breach = new Breach(sample.observedAtNanos(), + sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()), sample.horizontalDistance(), 0); + if (absolute) add(state.absoluteEvidence, + breach.withCapturedLimit(settings.horizontalSpeedLimit())); + if (contextual) add(state.contextualEvidence, breach.withCapturedLimit(expectedLimit)); + + boolean absoluteReady = absolute && state.absoluteEvidence.size() >= settings.breachesToAlert(); + boolean contextualReady = contextual && state.contextualEvidence.size() >= settings.breachesToAlert(); + if ((!absoluteReady && !contextualReady) || sample.observedAtNanos() < state.cooldownUntilNanos) { return Optional.empty(); } state.cooldownUntilNanos = sample.observedAtNanos() + seconds(settings.alertCooldownSeconds()); - var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow(); - return Optional.of(new AlertDecision(playerId, state.evidence.size(), peak.speed(), peak.capturedLimit())); + if (absoluteReady) { + var peak = state.absoluteEvidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow(); + return Optional.of(AlertDecision.absolute(playerId, state.absoluteEvidence.size(), + peak.speed(), peak.capturedLimit(), settings.evidenceWindowSeconds())); + } + var peak = state.contextualEvidence.stream().max(Comparator.comparingDouble(Breach::ratio)).orElseThrow(); + return Optional.of(AlertDecision.contextual(playerId, state.contextualEvidence.size(), peak.speed(), + peak.capturedLimit(), settings.evidenceWindowSeconds())); } public void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason, SentinelSettings settings) { @@ -53,65 +62,78 @@ public final class MovementDetector { public Optional inspect(UUID playerId, long nowNanos) { var state = players.get(playerId); - if (state == null) { - return Optional.empty(); - } + if (state == null) return Optional.empty(); expire(state, nowNanos); - if (state.evidence.isEmpty()) { - return Optional.empty(); + if (state.absoluteEvidence.isEmpty() && state.contextualEvidence.isEmpty()) return Optional.empty(); + GroundSpeedEvidence contextual = snapshot(state.contextualEvidence, DetectionSignal.CONTEXTUAL_GROUND_SPEED, + state, nowNanos).orElse(null); + if (state.absoluteEvidence.isEmpty()) { + return Optional.of(new InspectionSnapshot(0, 0, 0, 0, state.currentExclusion, + cooldown(state, nowNanos), null, contextual)); } - var first = state.evidence.getFirst(); - var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow(); - return Optional.of(new InspectionSnapshot( - state.evidence.size(), - Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0, - peak.speed(), - peak.capturedLimit(), - state.currentExclusion, - Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0)); + var first = state.absoluteEvidence.getFirst(); + var peak = state.absoluteEvidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow(); + return Optional.of(new InspectionSnapshot(state.absoluteEvidence.size(), age(first, nowNanos), + peak.speed(), peak.capturedLimit(), state.currentExclusion, cooldown(state, nowNanos), + null, contextual)); } public void cleanup(int currentTick, long nowNanos) { players.entrySet().removeIf(entry -> { var state = entry.getValue(); expire(state, nowNanos); - return state.evidence.isEmpty() && state.cooldownUntilNanos <= nowNanos - && currentTick > state.graceThroughTick; + return state.absoluteEvidence.isEmpty() && state.contextualEvidence.isEmpty() + && state.cooldownUntilNanos <= nowNanos && currentTick > state.graceThroughTick; }); } - public void forget(UUID playerId) { - players.remove(playerId); - } - - public void clear() { - players.clear(); - } - - int trackedPlayerCount() { - return players.size(); - } - + public void forget(UUID playerId) { players.remove(playerId); } + public void clear() { players.clear(); } + int trackedPlayerCount() { return players.size(); } int evidenceCount(UUID playerId) { var state = players.get(playerId); - return state == null ? 0 : state.evidence.size(); + return state == null ? 0 : state.absoluteEvidence.size() + state.contextualEvidence.size(); } - private static long seconds(int value) { - return value * 1_000_000_000L; + private static Optional snapshot(ArrayDeque evidence, DetectionSignal signal, + PlayerState state, long nowNanos) { + if (evidence.isEmpty()) return Optional.empty(); + var peak = evidence.stream().max(Comparator.comparingDouble(Breach::ratio)).orElseThrow(); + return Optional.of(new GroundSpeedEvidence(signal, evidence.size(), age(evidence.getFirst(), nowNanos), + peak.speed(), peak.capturedLimit(), peak.ratio(), state.currentExclusion, + cooldown(state, nowNanos))); } + private static void add(ArrayDeque evidence, Breach breach) { + if (evidence.size() == MAX_EVIDENCE_PER_PLAYER) evidence.removeFirst(); + evidence.addLast(breach); + } + + private static long seconds(int value) { return value * 1_000_000_000L; } + private static double age(Breach first, long nowNanos) { + return Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0; + } + private static double cooldown(PlayerState state, long nowNanos) { + return Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0; + } private static void expire(PlayerState state, long nowNanos) { - while (!state.evidence.isEmpty() && state.evidence.getFirst().expiresAtNanos() <= nowNanos) { - state.evidence.removeFirst(); - } + expire(state.absoluteEvidence, nowNanos); + expire(state.contextualEvidence, nowNanos); + } + private static void expire(ArrayDeque evidence, long nowNanos) { + while (!evidence.isEmpty() && evidence.getFirst().expiresAtNanos() <= nowNanos) evidence.removeFirst(); } private record Breach(long observedAtNanos, long expiresAtNanos, double speed, double capturedLimit) { + Breach withCapturedLimit(double value) { + return new Breach(observedAtNanos, expiresAtNanos, speed, value); + } + double ratio() { return speed / capturedLimit; } } private static final class PlayerState { - private final ArrayDeque evidence = new ArrayDeque<>(); + private final ArrayDeque absoluteEvidence = new ArrayDeque<>(); + private final ArrayDeque contextualEvidence = new ArrayDeque<>(); private int graceThroughTick = Integer.MIN_VALUE; private long cooldownUntilNanos; private ExclusionReason currentExclusion = ExclusionReason.NONE; diff --git a/src/main/java/games/dmg/purpursentinel/MovementFrame.java b/src/main/java/games/dmg/purpursentinel/MovementFrame.java index 846ac59..29a9804 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementFrame.java +++ b/src/main/java/games/dmg/purpursentinel/MovementFrame.java @@ -11,8 +11,15 @@ public record MovementFrame( double fromZ, double toX, double toZ, - ExclusionReason reason) { + ExclusionReason reason, + GroundMovementContext groundContext) { + public MovementFrame(int tick, long observedAtNanos, UUID fromWorld, UUID toWorld, + double fromX, double fromZ, double toX, double toZ, ExclusionReason reason) { + this(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ, reason, null); + } + public MovementFrame withReason(ExclusionReason replacement) { - return new MovementFrame(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ, replacement); + return new MovementFrame(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ, + replacement, groundContext); } } diff --git a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java index a8147f9..d8f4293 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java +++ b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java @@ -69,7 +69,7 @@ public final class MovementMonitor { var evidence = movement.get(); return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(), - evidence.cooldownRemainingSeconds(), flight.get())); + evidence.cooldownRemainingSeconds(), flight.get(), evidence.contextualEvidence())); } public void forget(UUID playerId) { @@ -96,7 +96,8 @@ public final class MovementMonitor { !bucket.invalid, bucket.reason == ExclusionReason.CANCELLED, bucket.maximumDistance, - bucket.reason), bucket.settings); + bucket.reason, + bucket.groundContext), bucket.settings); } private static final class TickBucket { @@ -106,6 +107,7 @@ public final class MovementMonitor { private final double anchorX; private final double anchorZ; private final SentinelSettings settings; + private final GroundMovementContext groundContext; private double lastX; private double lastZ; private double previousFromX; @@ -124,6 +126,7 @@ public final class MovementMonitor { anchorX = frame.fromX(); anchorZ = frame.fromZ(); this.settings = settings; + groundContext = frame.groundContext(); lastX = frame.fromX(); lastZ = frame.fromZ(); reason = ExclusionReason.NONE; @@ -137,7 +140,8 @@ public final class MovementMonitor { void add(MovementFrame frame) { if (!world.equals(frame.toWorld()) || !world.equals(frame.fromWorld()) || !finite(frame.fromX(), frame.fromZ(), frame.toX(), frame.toZ()) - || !connected(frame.fromX(), frame.fromZ())) { + || !connected(frame.fromX(), frame.fromZ()) + || !java.util.Objects.equals(groundContext, frame.groundContext())) { invalid = true; reason = ExclusionReason.INCOMPLETE; return; diff --git a/src/main/java/games/dmg/purpursentinel/MovementSample.java b/src/main/java/games/dmg/purpursentinel/MovementSample.java index ca1a046..e385a28 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementSample.java +++ b/src/main/java/games/dmg/purpursentinel/MovementSample.java @@ -6,5 +6,10 @@ public record MovementSample( boolean complete, boolean cancelled, double horizontalDistance, - ExclusionReason exclusionReason) { + ExclusionReason exclusionReason, + GroundMovementContext groundContext) { + public MovementSample(int tick, long observedAtNanos, boolean complete, boolean cancelled, + double horizontalDistance, ExclusionReason exclusionReason) { + this(tick, observedAtNanos, complete, cancelled, horizontalDistance, exclusionReason, null); + } } diff --git a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java index 55f9fb9..0653522 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java @@ -16,8 +16,14 @@ final class SentinelAlerts { 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()); + "[Sentinel] %s: ABSOLUTE_GROUND_SPEED; %d movement breaches in %ds window; peak %.2f blocks/tick vs captured limit %.2f; ratio %.2f. Use /sentinel inspect %s.", + subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(), + alert.capturedLimit(), alert.ratio(), subject.getName()); + } else if (alert.signal() == DetectionSignal.CONTEXTUAL_GROUND_SPEED) { + message = String.format(Locale.ROOT, + "[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 { 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 33829db..03c61e1 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java @@ -102,11 +102,20 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { flight.cruiseTicks(), flight.horizontalDistance(), flight.verticalDisplacement(), flight.evidenceAgeSeconds(), flight.currentExclusion().description(), flight.cooldownRemainingSeconds())); + } else if (evidence.contextualEvidence() != null) { + var contextual = evidence.contextualEvidence(); + sender.sendMessage(String.format(Locale.ROOT, + "%s: CONTEXTUAL_GROUND_SPEED; %d breaches; age %.1fs; observed %.2f blocks/tick; expected %.2f; ratio %.2f; exclusion %s; cooldown %.1fs; absolute breaches %d.", + player.get().getName(), contextual.breachCount(), contextual.evidenceAgeSeconds(), + contextual.observedSpeed(), contextual.expectedLimit(), contextual.ratio(), + contextual.currentExclusion().description(), contextual.cooldownRemainingSeconds(), + evidence.breachCount())); } else { sender.sendMessage(String.format(Locale.ROOT, - "%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.", + "%s: %d breaches; ABSOLUTE_GROUND_SPEED; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; ratio %.2f; exclusion %s; cooldown %.1fs.", player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(), - evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds())); + evidence.capturedLimit(), evidence.peakSpeed() / evidence.capturedLimit(), + evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds())); } return true; } diff --git a/src/main/java/games/dmg/purpursentinel/SentinelSettings.java b/src/main/java/games/dmg/purpursentinel/SentinelSettings.java index 88eea39..5fe357c 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelSettings.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelSettings.java @@ -10,12 +10,14 @@ public record SentinelSettings( int unsupportedFlightTicks, int unsupportedHoverTicks, double unsupportedAscentBlocks, - double unsupportedCruiseBlocks) { + double unsupportedCruiseBlocks, + double contextualSpeedRatio, + double contextualMinimumExcess) { 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); + minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0, 1.35, 0.10); } public SentinelSettings { @@ -29,10 +31,12 @@ public record SentinelSettings( 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"); + requireFiniteRange(contextualSpeedRatio, 1.05, 3.00, "contextual-speed-ratio"); + requireFiniteRange(contextualMinimumExcess, 0.01, 1.00, "contextual-minimum-excess"); } public static SentinelSettings defaults() { - return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0); + return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0, 1.35, 0.10); } 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 11c6ee4..79a8612 100644 --- a/src/main/java/games/dmg/purpursentinel/SettingKey.java +++ b/src/main/java/games/dmg/purpursentinel/SettingKey.java @@ -14,7 +14,9 @@ public enum SettingKey { 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); + 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); 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 b775c45..839b228 100644 --- a/src/main/java/games/dmg/purpursentinel/SettingsService.java +++ b/src/main/java/games/dmg/purpursentinel/SettingsService.java @@ -47,7 +47,9 @@ public final class SettingsService { 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)); + optionalDecimal(values, SettingKey.UNSUPPORTED_CRUISE_BLOCKS, 10.0), + optionalDecimal(values, SettingKey.CONTEXTUAL_SPEED_RATIO, 1.35), + optionalDecimal(values, SettingKey.CONTEXTUAL_MINIMUM_EXCESS, 0.10)); } private static Object parseValue(SettingKey key, String value) { @@ -108,6 +110,8 @@ public final class SettingsService { case UNSUPPORTED_HOVER_TICKS -> Integer.toString(settings.unsupportedHoverTicks()); case UNSUPPORTED_ASCENT_BLOCKS -> decimal(settings.unsupportedAscentBlocks(), 1); case UNSUPPORTED_CRUISE_BLOCKS -> decimal(settings.unsupportedCruiseBlocks(), 1); + case CONTEXTUAL_SPEED_RATIO -> decimal(settings.contextualSpeedRatio(), 2); + case CONTEXTUAL_MINIMUM_EXCESS -> decimal(settings.contextualMinimumExcess(), 2); }; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index fe9d66c..4a517ff 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -8,3 +8,5 @@ unsupported-flight-ticks: 40 unsupported-hover-ticks: 30 unsupported-ascent-blocks: 2.0 unsupported-cruise-blocks: 10.0 +contextual-speed-ratio: 1.35 +contextual-minimum-excess: 0.10 diff --git a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java index fc84885..43b69fa 100644 --- a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java @@ -15,6 +15,8 @@ import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Server; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; import org.bukkit.World; import org.bukkit.command.PluginCommand; import org.bukkit.entity.Player; @@ -90,6 +92,10 @@ class PluginLifecycleTest { when(subject.isOnline()).thenReturn(true); when(subject.getGameMode()).thenReturn(GameMode.SURVIVAL); when(subject.getPose()).thenReturn(Pose.STANDING); + when(subject.isOnGround()).thenReturn(true); + var movement = mock(AttributeInstance.class); + when(movement.getValue()).thenReturn(0.1); + when(subject.getAttribute(Attribute.MOVEMENT_SPEED)).thenReturn(movement); when(subject.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(operator.hasPermission("sentinel.alerts")).thenReturn(true); when(operator.hasPermission("sentinel.inspect")).thenReturn(true); @@ -114,7 +120,8 @@ class PluginLifecycleTest { } verify(operator).sendMessage(argThat((String message) -> message.contains("6 movement breaches"))); assertTrue(command.execute(operator, "sentinel", new String[]{"inspect", "Alex"})); - verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: 6 breaches"))); + verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: CONTEXTUAL_GROUND_SPEED") + && message.contains("absolute breaches 6"))); 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"})); @@ -133,6 +140,30 @@ class PluginLifecycleTest { } } + @Test void nativeAttributesAndPotionAmplifiersFeedTheContextEnvelope() { + var player = mock(Player.class); + var movement = mock(AttributeInstance.class); + var sneaking = mock(AttributeInstance.class); + when(movement.getValue()).thenReturn(0.15); + when(sneaking.getValue()).thenReturn(0.4); + when(player.getAttribute(Attribute.MOVEMENT_SPEED)).thenReturn(movement); + when(player.getAttribute(Attribute.SNEAKING_SPEED)).thenReturn(sneaking); + when(player.isSprinting()).thenReturn(true); + when(player.isOnGround()).thenReturn(false); + when(player.getActivePotionEffects()).thenReturn(java.util.Set.of( + new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SPEED, 100, 2))); + + var context = BukkitMovementListener.groundContext(player); + + assertNotNull(context); + assertEquals(0.15, context.movementSpeedAttribute(), 1e-9); + assertEquals(0.4, context.sneakingMultiplier(), 1e-9); + assertTrue(context.sprinting()); + assertFalse(context.grounded()); + assertEquals(2, context.speedAmplifier()); + assertEquals(-1, context.slownessAmplifier()); + } + @Test void invalidStartupConfigurationRegistersNothing() throws Exception { Files.writeString(directory.toPath().resolve("config.yml"), "minimum-tps: bad\n"); var server = mock(Server.class); diff --git a/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java b/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java index 7a1e46e..ad6ccc7 100644 --- a/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java +++ b/src/test/java/games/dmg/purpursentinel/BukkitMovementListenerTest.java @@ -40,6 +40,49 @@ class BukkitMovementListenerTest { assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty(), name); } + @Test void registeredMovementPathCapturesServerAuthoritativeGroundContext() { + var fixture = fixture(); + fixture.groundContext.set(GroundMovementContext.sprinting(0.15, false)); + + for (int tick = 1; tick <= 3; tick++) { + when(fixture.server.getCurrentTick()).thenReturn(tick); + fixture.listener.onMove(move(fixture.player, fixture.world, 0, 0.79, false)); + fixture.monitor.finishBefore(tick + 1, (tick + 1) * 50_000_000L); + } + + var evidence = fixture.monitor.inspect(fixture.player.getUniqueId(), 200_000_000L) + .orElseThrow().contextualEvidence(); + assertEquals(3, evidence.breachCount()); + assertEquals(DetectionSignal.CONTEXTUAL_GROUND_SPEED, evidence.signal()); + assertTrue(evidence.expectedLimit() > 0.15); + } + + @Test void speedAndSlownessAreModeledWhileOtherEffectsRemainExcluded() { + var fixture = fixture(); + fixture.groundContext.set(GroundMovementContext.withEffects(0.1, false, false, true, 2, -1)); + + fixture.listener.onMove(move(fixture.player, fixture.world, 0, 0.45, false)); + fixture.monitor.finishBefore(2, 100_000_000L); + assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty()); + } + + @Test void ambiguousNativeStatesExcludeContextualEvidence() { + for (var arrange : java.util.List.>of( + f -> when(f.player.isInsideVehicle()).thenReturn(true), + f -> when(f.player.isGliding()).thenReturn(true), + f -> when(f.player.isSwimming()).thenReturn(true), + f -> when(f.player.isClimbing()).thenReturn(true), + f -> when(f.player.isRiptiding()).thenReturn(true), + f -> when(f.player.getAllowFlight()).thenReturn(true), + f -> when(f.server.getTPS()).thenReturn(new double[]{17.9, 20, 20}))) { + var fixture = fixture(); + arrange.accept(fixture); + fixture.listener.onMove(move(fixture.player, fixture.world, 0, 0.79, false)); + fixture.monitor.finishBefore(2, 100_000_000L); + assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty()); + } + } + @Test void unsupportedSurvivalMovementFeedsFlightEvidenceWithoutTrustingFlyingAsAuthorization() { var fixture = fixture(); when(fixture.player.isFlying()).thenReturn(true); @@ -139,9 +182,10 @@ class BukkitMovementListenerTest { when(player.getPose()).thenReturn(Pose.STANDING); var monitor = new MovementMonitor(new MovementDetector()); var clock = new AtomicLong(50_000_000L); + var groundContext = new java.util.concurrent.atomic.AtomicReference<>(GroundMovementContext.walking(0.1)); var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get, - (from, to) -> true, location -> support); - return new Fixture(server, world, player, monitor, listener); + (from, to) -> true, location -> support, ignored -> groundContext.get()); + return new Fixture(server, world, player, monitor, listener, groundContext); } private static PlayerMoveEvent move(Player player, World world, double from, double to, boolean cancelled) { @@ -150,5 +194,7 @@ class BukkitMovementListenerTest { return event; } - private record Fixture(Server server, World world, Player player, MovementMonitor monitor, BukkitMovementListener listener) {} + private record Fixture(Server server, World world, Player player, MovementMonitor monitor, + BukkitMovementListener listener, + java.util.concurrent.atomic.AtomicReference groundContext) {} } diff --git a/src/test/java/games/dmg/purpursentinel/ContextualGroundSpeedTest.java b/src/test/java/games/dmg/purpursentinel/ContextualGroundSpeedTest.java new file mode 100644 index 0000000..0ae2a3b --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/ContextualGroundSpeedTest.java @@ -0,0 +1,149 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class ContextualGroundSpeedTest { + private static final UUID WORLD = UUID.randomUUID(); + private static final SentinelSettings SETTINGS = SentinelSettings.defaults(); + + @Test void repeatedModerateOverspeedAlertsBelowAbsoluteThreshold() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var walking = GroundMovementContext.walking(0.1); + + for (int tick = 1; tick <= 5; tick++) { + record(monitor, player, tick, 0.34, walking); + assertTrue(monitor.finishBefore(tick + 1, nanos(tick + 1)).isEmpty()); + } + record(monitor, player, 6, 0.34, walking); + var alert = monitor.finishBefore(7, nanos(7)).getFirst(); + + assertEquals(DetectionSignal.CONTEXTUAL_GROUND_SPEED, alert.signal()); + assertEquals(6, alert.breachCount()); + assertEquals(0.34, alert.peakSpeed(), 1e-9); + assertEquals(0.22, alert.capturedLimit(), 1e-9); + assertEquals(0.34 / 0.22, alert.ratio(), 1e-9); + assertTrue(alert.peakSpeed() < SETTINGS.horizontalSpeedLimit()); + } + + @Test void ratioAndMinimumExcessMustBothBeExceeded() { + var monitor = new MovementMonitor(new MovementDetector()); + var walking = GroundMovementContext.walking(0.1); + var player = UUID.randomUUID(); + + IntStream.rangeClosed(1, 10).forEach(tick -> { + record(monitor, player, tick, 0.30, walking); // Ratio exceeds 1.35, excess is only 0.08. + assertTrue(monitor.finishBefore(tick + 1, nanos(tick + 1)).isEmpty()); + }); + assertTrue(monitor.inspect(player, nanos(11)).isEmpty()); + } + + @Test void legalLocomotionContextsDoNotCreateEvidence() { + var contexts = new GroundMovementContext[] { + GroundMovementContext.walking(0.1), + GroundMovementContext.sprinting(0.1, true), + GroundMovementContext.sneaking(0.1), + GroundMovementContext.walking(0.15), + GroundMovementContext.withEffects(0.1, false, false, true, 1, -1), + GroundMovementContext.withEffects(0.1, false, false, true, -1, 1) + }; + double[] legalSpeeds = {0.22, 0.37, 0.07, 0.33, 0.31, 0.16}; + + for (int index = 0; index < contexts.length; index++) { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + for (int tick = 1; tick <= 10; tick++) { + record(monitor, player, tick, legalSpeeds[index], contexts[index]); + assertTrue(monitor.finishBefore(tick + 1, nanos(tick + 1)).isEmpty()); + } + assertTrue(monitor.inspect(player, nanos(11)).isEmpty(), "context " + index); + } + } + + @Test void effectiveAttributeIsNotDoubleCountedWithStateMetadata() { + var context = new GroundMovementContext(0.13, 0.3, true, false, true, 1, -1); + assertEquals(0.13 * 2.2, context.expectedSpeed(), 1e-9); + } + + @Test void everySupportedPotionAmplifierChangesTheExpectedEnvelope() { + for (int amplifier = 0; amplifier <= 4; amplifier++) { + double expected = 0.22 * (1.0 + 0.20 * (amplifier + 1)); + assertEquals(expected, + GroundMovementContext.withEffects(0.1, false, false, true, amplifier, -1).expectedSpeed(), + 1e-9, "speed amplifier " + amplifier); + } + for (int amplifier = 0; amplifier <= 3; amplifier++) { + double expected = 0.22 * Math.max(0.10, 1.0 - 0.15 * (amplifier + 1)); + assertEquals(expected, + GroundMovementContext.withEffects(0.1, false, false, true, -1, amplifier).expectedSpeed(), + 1e-9, "slowness amplifier " + amplifier); + } + } + + @Test void absoluteSafeguardRemainsIndependentOfContextualEnvelope() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var fastAttribute = GroundMovementContext.walking(0.5); + + for (int tick = 1; tick <= 6; tick++) { + record(monitor, player, tick, 0.90, fastAttribute); + } + var alerts = monitor.finishBefore(7, nanos(7)); + assertEquals(1, alerts.size()); + var alert = alerts.getFirst(); + assertEquals(DetectionSignal.ABSOLUTE_GROUND_SPEED, alert.signal()); + assertNull(monitor.inspect(player, nanos(7)).orElseThrow().contextualEvidence(), + "legal context remains legal while the independent absolute ceiling alerts"); + assertEquals(0.80, alert.capturedLimit(), 1e-9); + } + + @Test void exclusionClearsActiveContextWhilePreservingBoundedPriorEvidence() { + var detector = new MovementDetector(); + var player = UUID.randomUUID(); + var context = GroundMovementContext.walking(0.1); + detector.observe(player, new MovementSample(1, nanos(1), true, false, 0.34, + ExclusionReason.NONE, context), SETTINGS); + detector.observe(player, new MovementSample(2, nanos(2), true, false, 2.0, + ExclusionReason.ENVIRONMENT, null), SETTINGS); + + var snapshot = detector.inspect(player, nanos(2)).orElseThrow().contextualEvidence(); + assertEquals(1, snapshot.breachCount()); + assertEquals(ExclusionReason.ENVIRONMENT, snapshot.currentExclusion()); + } + + @Test void oneSpikeDuplicateEventsBrokenChainsAndCleanupCannotInflateEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + var player = UUID.randomUUID(); + var context = GroundMovementContext.walking(0.1); + monitor.record(player, frame(1, 0, 0.34, context), SETTINGS); + monitor.record(player, frame(1, 0, 0.34, context), SETTINGS); + monitor.finishBefore(2, nanos(2)); + assertEquals(1, monitor.inspect(player, nanos(2)).orElseThrow().contextualEvidence().breachCount()); + + monitor.record(player, frame(2, 0.34, 0.68, context), SETTINGS); + monitor.record(player, frame(2, 5, 5.34, context), SETTINGS); + monitor.finishBefore(3, nanos(3)); + assertEquals(1, monitor.inspect(player, nanos(3)).orElseThrow().contextualEvidence().breachCount()); + + monitor.forget(player); + assertTrue(monitor.inspect(player, nanos(3)).isEmpty()); + } + + private static void record(MovementMonitor monitor, UUID player, int tick, double distance, + GroundMovementContext context) { + monitor.record(player, frame(tick, 0, distance, context), SETTINGS); + } + + private static MovementFrame frame(int tick, double from, double to, GroundMovementContext context) { + return new MovementFrame(tick, nanos(tick), WORLD, WORLD, from, 0, to, 0, + ExclusionReason.NONE, context); + } + + private static long nanos(int tick) { + return tick * 50_000_000L; + } +} diff --git a/src/test/java/games/dmg/purpursentinel/DistributionTest.java b/src/test/java/games/dmg/purpursentinel/DistributionTest.java index 26df0a5..342a84f 100644 --- a/src/test/java/games/dmg/purpursentinel/DistributionTest.java +++ b/src/test/java/games/dmg/purpursentinel/DistributionTest.java @@ -33,6 +33,8 @@ class DistributionTest { assertTrue(config.contains("unsupported-hover-ticks: 30")); assertTrue(config.contains("unsupported-ascent-blocks: 2.0")); assertTrue(config.contains("unsupported-cruise-blocks: 10.0")); + assertTrue(config.contains("contextual-speed-ratio: 1.35")); + assertTrue(config.contains("contextual-minimum-excess: 0.10")); 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/MovementEnvironmentTest.java b/src/test/java/games/dmg/purpursentinel/MovementEnvironmentTest.java new file mode 100644 index 0000000..bc2d1f8 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/MovementEnvironmentTest.java @@ -0,0 +1,40 @@ +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 MovementEnvironmentTest { + @ParameterizedTest(name = "{0}") @MethodSource("ambiguousMaterials") + void ambiguousMovementGeometryIsNeverOrdinary(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 || material == Material.LAVA); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + + assertFalse(MovementEnvironment.isOrdinary(new Location(world, 0.5, 64, 0.5))); + } + + @org.junit.jupiter.api.Test void unloadedGeometryIsUnknownRatherThanLegal() { + var world = mock(World.class); + when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false); + assertFalse(MovementEnvironment.isOrdinary(new Location(world, 0.5, 64, 0.5))); + verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + + private static Stream ambiguousMaterials() { + return Stream.of(Material.ICE, Material.PACKED_ICE, Material.BLUE_ICE, Material.SLIME_BLOCK, + Material.HONEY_BLOCK, Material.SOUL_SAND, Material.SOUL_SOIL, Material.WATER, + Material.LADDER, Material.PISTON, Material.MOVING_PISTON); + } +} diff --git a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java index 7b4374e..afe7ff5 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java @@ -30,6 +30,27 @@ class SentinelAlertsTest { verify(server, never()).getConsoleSender(); } + @Test @SuppressWarnings({"unchecked", "rawtypes"}) + void contextualAlertNamesSignalExpectedLimitRatioAndWindowWithoutCoordinates() { + 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(AlertDecision.contextual(id, 6, 0.34, 0.22, 10)); + + verify(operator).sendMessage(argThat((String message) -> message.contains("CONTEXTUAL_GROUND_SPEED") + && message.contains("0.34") && message.contains("0.22") && message.contains("1.55") + && message.contains("6 breaches") && message.contains("10s window") + && !message.contains("x=") && !message.contains("z=") + && !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 abb80f0..beac218 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java @@ -35,6 +35,25 @@ class SentinelCommandTest { verify(denied).sendMessage("You do not have permission to inspect Sentinel evidence."); } + @Test void inspectDistinguishesContextualFromAbsoluteEvidence() throws Exception { + var fixture = fixture(); + var target = online(fixture.server, "Runner"); + var sender = permitted("sentinel.inspect"); + var context = GroundMovementContext.walking(0.1); + var world = UUID.randomUUID(); + for (int tick = 1; tick <= 6; tick++) { + fixture.monitor.record(target.getUniqueId(), new MovementFrame(tick, tick * 50_000_000L, + world, world, 0, 0, 0.34, 0, ExclusionReason.NONE, context), fixture.settings.current()); + fixture.monitor.finishBefore(tick + 1, (tick + 1) * 50_000_000L); + } + + fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Runner"}); + verify(sender).sendMessage(argThat((String message) -> message.contains("CONTEXTUAL_GROUND_SPEED") + && message.contains("6 breaches") && message.contains("observed 0.34") + && message.contains("expected 0.22") && message.contains("ratio 1.55") + && message.contains("exclusion none") && message.contains("cooldown"))); + } + @Test void inspectDistinguishesUnknownOfflineAndNoCurrentEvidenceWithoutMutation() throws Exception { var fixture = fixture(); var sender = permitted("sentinel.inspect"); diff --git a/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java b/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java index cb63eba..298618f 100644 --- a/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java +++ b/src/test/java/games/dmg/purpursentinel/SettingsServiceTest.java @@ -91,6 +91,27 @@ class SettingsServiceTest { () -> service.update(SettingKey.UNSUPPORTED_CRUISE_BLOCKS, "101")); } + @Test void contextualThresholdsDefaultLoadPersistAndRemainBackwardCompatible() throws Exception { + var file = directory.resolve("config.yml"); + AtomicYamlSettingsStore.writeDefaults(file); + var service = SettingsService.load(new AtomicYamlSettingsStore(file)); + assertEquals(1.35, service.current().contextualSpeedRatio(), 1e-9); + assertEquals(0.10, service.current().contextualMinimumExcess(), 1e-9); + assertEquals("1.50", service.update(SettingKey.CONTEXTUAL_SPEED_RATIO, "1.50")); + assertEquals("0.15", service.update(SettingKey.CONTEXTUAL_MINIMUM_EXCESS, "0.15")); + assertEquals(service.current(), SettingsService.load(new AtomicYamlSettingsStore(file)).current()); + + var old = directory.resolve("old.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(1.35, compatible.current().contextualSpeedRatio(), 1e-9); + assertEquals(0.10, compatible.current().contextualMinimumExcess(), 1e-9); + assertArrayEquals(before, Files.readAllBytes(old)); + } + @Test void malformedStartupConfigurationFailsBeforeUse() throws Exception { var file = directory.resolve("config.yml"); AtomicYamlSettingsStore.writeDefaults(file);