feat(sentinel): detect unauthorized flight
Release / release (push) Successful in 1m49s
CI / build (push) Successful in 1m8s

This commit was merged in pull request #2.
This commit is contained in:
dmg committed 2026-10-29 20:17:44 +00:00
commit 867b8bbaed
27 files changed
+849 -27

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