feat(sentinel): detect contextual ground speed
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:
commit
062e390386
27 files changed
+608
-81
No files matched your search
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user