feat(sentinel): detect contextual ground speed
CI / build (push) Successful in 1m7s
Release / release (push) Failing after 11m29s

Merge PR #3 for Kaneo SM-346 after exact-candidate checks 3812 and 3813 passed.
This commit was merged in pull request #3.
This commit is contained in:
dmg committed 2026-10-29 20:47:00 +00:00
commit 062e390386
27 files changed
+608 -81

No files matched your search

+1 -1
View File
@@ -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
@@ -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());
}
}
@@ -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);
}
}
@@ -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<Location, Location> ordinaryEnvironment;
private final Function<Location, SupportState> supportState;
private final Function<Player, GroundMovementContext> groundMovementContext;
private final Map<UUID, Location> lastFlightSamples = new HashMap<>();
public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) {
@@ -43,18 +45,28 @@ public final class BukkitMovementListener implements Listener {
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> 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<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState,
BukkitMovementListener::groundContext);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState,
Function<Player, GroundMovementContext> 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
@@ -2,6 +2,7 @@ package games.dmg.purpursentinel;
public enum DetectionSignal {
ABSOLUTE_GROUND_SPEED,
CONTEXTUAL_GROUND_SPEED,
UNAUTHORIZED_FLIGHT_STATE,
UNSUPPORTED_ASCENT,
UNSUPPORTED_HOVER,
@@ -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;
};
}
@@ -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;
}
}
@@ -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) {
}
@@ -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);
}
}
@@ -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<InspectionSnapshot> 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<GroundSpeedEvidence> snapshot(ArrayDeque<Breach> 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<Breach> 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<Breach> 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<Breach> evidence = new ArrayDeque<>();
private final ArrayDeque<Breach> absoluteEvidence = new ArrayDeque<>();
private final ArrayDeque<Breach> contextualEvidence = new ArrayDeque<>();
private int graceThroughTick = Integer.MIN_VALUE;
private long cooldownUntilNanos;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
@@ -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);
}
}
@@ -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;
@@ -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);
}
}
@@ -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.",
@@ -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;
}
@@ -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) {
@@ -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;
@@ -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);
};
}
+2
View File
@@ -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
@@ -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);
@@ -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.<java.util.function.Consumer<Fixture>>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<GroundMovementContext> groundContext) {}
}
@@ -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;
}
}
@@ -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))) {
@@ -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<Material> 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);
}
}
@@ -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);
@@ -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");
@@ -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);