Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e506cb79f0 | ||
|
|
28e72e7349 | ||
|
|
a31966d148 | ||
|
|
1c226d14ef | ||
|
|
062e390386 | ||
|
|
0db3f32bd3 |
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 suspicious on-foot speed, unauthorized unsupported flight, unsupported boat travel, source-less elytra propulsion, and statistically implausible elytra wear. Elytra analysis correlates server-recognized gliding, equipped-item identity, observed rocket use, external propulsion, altitude conversion, wear opportunities, Unbreaking, Mending/XP repair, and unbreakable items; high speed alone is never evidence. Sentinel does not cancel movement, alter vehicles or items, consume rockets, change flight permissions, punish players, persist player evidence, or export telemetry.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -8,16 +8,50 @@ public record AlertDecision(
|
||||
int breachCount,
|
||||
double peakSpeed,
|
||||
double capturedLimit,
|
||||
double ratio,
|
||||
int evidenceWindowSeconds,
|
||||
int eligibleTicks,
|
||||
double horizontalDistance,
|
||||
double verticalDisplacement) {
|
||||
double verticalDisplacement,
|
||||
String vehicleType) {
|
||||
|
||||
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, null);
|
||||
}
|
||||
|
||||
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, null);
|
||||
}
|
||||
|
||||
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, null);
|
||||
}
|
||||
|
||||
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, null);
|
||||
}
|
||||
|
||||
public static AlertDecision flight(UUID playerId, FlightEvidence evidence) {
|
||||
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, evidence.eligibleTicks(),
|
||||
evidence.horizontalDistance(), evidence.verticalDisplacement());
|
||||
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(),
|
||||
evidence.horizontalDistance(), evidence.verticalDisplacement(), null);
|
||||
}
|
||||
|
||||
public static AlertDecision boat(UUID playerId, BoatEvidence evidence) {
|
||||
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(),
|
||||
evidence.horizontalDistance(), evidence.verticalDisplacement(), evidence.vehicleType());
|
||||
}
|
||||
|
||||
public static AlertDecision elytra(UUID playerId, ElytraEvidence evidence) {
|
||||
return new AlertDecision(playerId, evidence.signal(), evidence.wearOpportunities(),
|
||||
evidence.peakAcceleration(), evidence.unbreakingLevel(), 0, evidence.observedBoosts(),
|
||||
evidence.eligibleTicks(), 0, evidence.ascent(), null);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ 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
|
||||
unsupported-boat-ticks: 40
|
||||
unsupported-boat-hover-ticks: 30
|
||||
unsupported-boat-ascent-blocks: 2.0
|
||||
unsupported-boat-cruise-blocks: 12.0
|
||||
""", StandardOpenOption.CREATE_NEW);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class BoatDetector {
|
||||
private final Map<UUID, State> boats = new HashMap<>();
|
||||
private final Map<UUID, UUID> controllerBoats = new HashMap<>();
|
||||
|
||||
Optional<AlertDecision> observe(BoatFrame frame, SentinelSettings settings) {
|
||||
releaseOtherBoat(frame.controllerId(), frame.vehicleId());
|
||||
var state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State());
|
||||
if (state.controller != null && !state.controller.equals(frame.controllerId())) release(frame.vehicleId());
|
||||
state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State());
|
||||
controllerBoats.put(frame.controllerId(), frame.vehicleId());
|
||||
state.controller = frame.controllerId();
|
||||
state.vehicleId = frame.vehicleId();
|
||||
state.vehicleType = frame.vehicleType();
|
||||
state.expire(frame.observedAtNanos());
|
||||
|
||||
if (ineligible(frame)) {
|
||||
state.resetSequence();
|
||||
state.supportState = frame.supportState();
|
||||
state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE
|
||||
? ExclusionReason.ENVIRONMENT : frame.exclusionReason();
|
||||
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.supportState = SupportState.UNSUPPORTED;
|
||||
state.currentExclusion = ExclusionReason.NONE;
|
||||
double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
|
||||
double vertical = frame.toY() - frame.fromY();
|
||||
state.eligibleTicks++;
|
||||
state.horizontalDistance += horizontal;
|
||||
state.verticalDisplacement = frame.toY() - state.startY;
|
||||
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, 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.boat(frame.controllerId(), state.lastEvidence));
|
||||
}
|
||||
|
||||
Optional<BoatEvidence> inspect(UUID playerId, long nowNanos) {
|
||||
UUID boatId = controllerBoats.get(playerId);
|
||||
if (boatId == null) return Optional.empty();
|
||||
var state = boats.get(boatId);
|
||||
if (state == null || !playerId.equals(state.controller)) return Optional.empty();
|
||||
state.expire(nowNanos);
|
||||
return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos));
|
||||
}
|
||||
|
||||
void release(UUID boatId) {
|
||||
var state = boats.remove(boatId);
|
||||
if (state != null && state.controller != null) controllerBoats.remove(state.controller, boatId);
|
||||
}
|
||||
|
||||
void forget(UUID playerId) {
|
||||
var boatId = controllerBoats.remove(playerId);
|
||||
if (boatId != null) boats.remove(boatId);
|
||||
}
|
||||
|
||||
void cleanup(int currentTick, long nowNanos) {
|
||||
var iterator = boats.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
var entry = iterator.next();
|
||||
var state = entry.getValue();
|
||||
state.expire(nowNanos);
|
||||
if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence();
|
||||
if (state.world == null && state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos
|
||||
&& currentTick > state.graceThroughTick) {
|
||||
controllerBoats.remove(state.controller, entry.getKey());
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clear() { boats.clear(); controllerBoats.clear(); }
|
||||
|
||||
private void releaseOtherBoat(UUID controllerId, UUID currentBoat) {
|
||||
var previous = controllerBoats.get(controllerId);
|
||||
if (previous != null && !previous.equals(currentBoat)) release(previous);
|
||||
}
|
||||
|
||||
private static boolean ineligible(BoatFrame frame) {
|
||||
return 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, SentinelSettings settings) {
|
||||
if (state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks()) {
|
||||
return DetectionSignal.UNSUPPORTED_BOAT_ASCENT;
|
||||
}
|
||||
if (state.hoverTicks >= settings.unsupportedBoatHoverTicks()) {
|
||||
return DetectionSignal.UNSUPPORTED_BOAT_HOVER;
|
||||
}
|
||||
return DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
|
||||
}
|
||||
|
||||
private static boolean thresholdReached(State state, SentinelSettings settings) {
|
||||
return switch (state.signal) {
|
||||
case UNSUPPORTED_BOAT_ASCENT -> state.eligibleTicks >= settings.unsupportedBoatTicks()
|
||||
&& state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks();
|
||||
case UNSUPPORTED_BOAT_HOVER -> state.hoverTicks >= settings.unsupportedBoatHoverTicks();
|
||||
case UNSUPPORTED_BOAT_CRUISE -> state.cruiseTicks >= settings.unsupportedBoatTicks()
|
||||
&& state.horizontalDistance >= settings.unsupportedBoatCruiseBlocks();
|
||||
default -> 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 vehicleId;
|
||||
private UUID controller;
|
||||
private UUID world;
|
||||
private String vehicleType = "UNKNOWN_BOAT";
|
||||
private int lastTick = Integer.MIN_VALUE;
|
||||
private int graceThroughTick = Integer.MIN_VALUE;
|
||||
private long firstObservedAtNanos;
|
||||
private long evidenceFirstObservedAtNanos;
|
||||
private long evidenceExpiresAtNanos;
|
||||
private long cooldownUntilNanos;
|
||||
private double startY;
|
||||
private int eligibleTicks;
|
||||
private int ascentTicks;
|
||||
private int hoverTicks;
|
||||
private int cruiseTicks;
|
||||
private double horizontalDistance;
|
||||
private double verticalDisplacement;
|
||||
private SupportState supportState = SupportState.UNKNOWN;
|
||||
private ExclusionReason currentExclusion = ExclusionReason.NONE;
|
||||
private DetectionSignal signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
|
||||
private BoatEvidence lastEvidence;
|
||||
|
||||
private boolean connected(BoatFrame frame) {
|
||||
return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick();
|
||||
}
|
||||
private void startSequence(BoatFrame 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 = ascentTicks = hoverTicks = cruiseTicks = 0;
|
||||
horizontalDistance = verticalDisplacement = 0;
|
||||
signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
|
||||
}
|
||||
private void expire(long nowNanos) {
|
||||
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
|
||||
}
|
||||
private BoatEvidence evidence(long nowNanos) {
|
||||
var source = lastEvidence;
|
||||
int ticks = eligibleTicks, climbs = ascentTicks, hovers = hoverTicks, cruises = cruiseTicks;
|
||||
double horizontal = horizontalDistance, vertical = verticalDisplacement;
|
||||
DetectionSignal currentSignal = signal;
|
||||
long first = firstObservedAtNanos;
|
||||
if (eligibleTicks == 0 && source != null) {
|
||||
ticks = source.eligibleTicks(); climbs = source.ascentTicks(); hovers = source.hoverTicks();
|
||||
cruises = source.cruiseTicks(); horizontal = source.horizontalDistance();
|
||||
vertical = source.verticalDisplacement(); currentSignal = source.signal();
|
||||
first = evidenceFirstObservedAtNanos;
|
||||
}
|
||||
return new BoatEvidence(currentSignal, vehicleId, vehicleType, supportState, ticks, climbs, hovers,
|
||||
cruises, 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,19 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record BoatEvidence(
|
||||
DetectionSignal signal,
|
||||
UUID vehicleId,
|
||||
String vehicleType,
|
||||
SupportState supportState,
|
||||
int eligibleTicks,
|
||||
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 BoatFrame(
|
||||
int tick,
|
||||
long observedAtNanos,
|
||||
UUID vehicleId,
|
||||
UUID controllerId,
|
||||
UUID world,
|
||||
String vehicleType,
|
||||
double fromX,
|
||||
double fromY,
|
||||
double fromZ,
|
||||
double toX,
|
||||
double toY,
|
||||
double toZ,
|
||||
SupportState supportState,
|
||||
ExclusionReason exclusionReason) {
|
||||
|
||||
public static BoatFrame unsupported(int tick, long observedAtNanos, UUID vehicleId, UUID controllerId,
|
||||
UUID world, String vehicleType, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ) {
|
||||
return new BoatFrame(tick, observedAtNanos, vehicleId, controllerId, world, vehicleType,
|
||||
fromX, fromY, fromZ, toX, toY, toZ, SupportState.UNSUPPORTED, ExclusionReason.NONE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Boat;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityTeleportEvent;
|
||||
import org.bukkit.event.vehicle.VehicleBlockCollisionEvent;
|
||||
import org.bukkit.event.vehicle.VehicleDestroyEvent;
|
||||
import org.bukkit.event.vehicle.VehicleEnterEvent;
|
||||
import org.bukkit.event.vehicle.VehicleExitEvent;
|
||||
|
||||
final class BukkitBoatObserver implements Listener {
|
||||
private final Server server;
|
||||
private final MovementMonitor monitor;
|
||||
private final Supplier<SentinelSettings> settings;
|
||||
private final LongSupplier nanoTime;
|
||||
private final Function<Location, SupportState> supportState;
|
||||
private final Map<UUID, Sample> lastSamples = new HashMap<>();
|
||||
|
||||
BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
|
||||
LongSupplier nanoTime) {
|
||||
this(server, monitor, settings, nanoTime, MovementEnvironment::boatSupportState);
|
||||
}
|
||||
|
||||
BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
|
||||
LongSupplier nanoTime, Function<Location, SupportState> supportState) {
|
||||
this.server = server;
|
||||
this.monitor = monitor;
|
||||
this.settings = settings;
|
||||
this.nanoTime = nanoTime;
|
||||
this.supportState = supportState;
|
||||
}
|
||||
|
||||
void sample() {
|
||||
int tick = server.getCurrentTick();
|
||||
long observedAtNanos = nanoTime.getAsLong();
|
||||
var seen = new HashSet<UUID>();
|
||||
for (var world : server.getWorlds()) {
|
||||
for (Boat boat : world.getEntitiesByClass(Boat.class)) {
|
||||
if (!isBoatFamily(boat.getType()) || !boat.isValid() || boat.isDead()) {
|
||||
release(boat.getUniqueId());
|
||||
continue;
|
||||
}
|
||||
seen.add(boat.getUniqueId());
|
||||
var controller = boat.getPassengers().stream().filter(Player.class::isInstance)
|
||||
.map(Player.class::cast).findFirst().orElse(null);
|
||||
if (controller == null) {
|
||||
release(boat.getUniqueId());
|
||||
continue;
|
||||
}
|
||||
Location current = boat.getLocation();
|
||||
if (current == null || current.getWorld() == null) {
|
||||
release(boat.getUniqueId());
|
||||
continue;
|
||||
}
|
||||
Sample prior = lastSamples.get(boat.getUniqueId());
|
||||
ExclusionReason reason = classify(boat, controller, current, prior);
|
||||
Location previous = prior == null || !prior.world.equals(current.getWorld().getUID())
|
||||
? current : prior.location;
|
||||
monitor.record(new BoatFrame(tick, observedAtNanos, boat.getUniqueId(), controller.getUniqueId(),
|
||||
current.getWorld().getUID(), boat.getType().name(), previous.getX(), previous.getY(), previous.getZ(),
|
||||
current.getX(), current.getY(), current.getZ(), supportState.apply(current), reason), settings.get());
|
||||
lastSamples.put(boat.getUniqueId(), new Sample(current.clone(), current.getWorld().getUID(),
|
||||
controller.getUniqueId(), current.getBlockX() >> 4, current.getBlockZ() >> 4));
|
||||
}
|
||||
}
|
||||
for (UUID boatId : new HashSet<>(lastSamples.keySet())) {
|
||||
if (!seen.contains(boatId)) release(boatId);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onEnter(VehicleEnterEvent event) { releaseIfBoat(event.getVehicle()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onExit(VehicleExitEvent event) { releaseIfBoat(event.getVehicle()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDestroy(VehicleDestroyEvent event) { releaseIfBoat(event.getVehicle()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onCollision(VehicleBlockCollisionEvent event) { releaseIfBoat(event.getVehicle()); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onTeleport(EntityTeleportEvent event) {
|
||||
if (event.getEntity() instanceof Boat boat) release(boat.getUniqueId());
|
||||
}
|
||||
|
||||
void clear() {
|
||||
lastSamples.clear();
|
||||
}
|
||||
|
||||
int trackedBoatCount() { return lastSamples.size(); }
|
||||
|
||||
static boolean isBoatFamily(EntityType type) {
|
||||
String name = type.name();
|
||||
return name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT")
|
||||
|| name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT");
|
||||
}
|
||||
|
||||
private ExclusionReason classify(Boat boat, Player controller, Location current, Sample prior) {
|
||||
if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS;
|
||||
if (prior == null || !prior.world.equals(current.getWorld().getUID())
|
||||
|| !prior.controller.equals(controller.getUniqueId())
|
||||
|| prior.chunkX != current.getBlockX() >> 4 || prior.chunkZ != current.getBlockZ() >> 4) {
|
||||
return ExclusionReason.LIFECYCLE;
|
||||
}
|
||||
double displacement = Math.sqrt(prior.location.distanceSquared(current));
|
||||
if (!Double.isFinite(displacement) || displacement > 8.0) return ExclusionReason.LIFECYCLE;
|
||||
var velocity = boat.getVelocity();
|
||||
if (velocity != null && (Math.abs(velocity.getX()) > 1.5 || Math.abs(velocity.getY()) > 1.0
|
||||
|| Math.abs(velocity.getZ()) > 1.5)) return ExclusionReason.VELOCITY;
|
||||
return ExclusionReason.NONE;
|
||||
}
|
||||
|
||||
private void releaseIfBoat(org.bukkit.entity.Vehicle vehicle) {
|
||||
if (vehicle instanceof Boat boat) release(boat.getUniqueId());
|
||||
}
|
||||
|
||||
private void release(UUID boatId) {
|
||||
lastSamples.remove(boatId);
|
||||
monitor.releaseBoat(boatId);
|
||||
}
|
||||
|
||||
private record Sample(Location location, UUID world, UUID controller, int chunkX, int chunkZ) {}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityToggleGlideEvent;
|
||||
import org.bukkit.event.player.PlayerExpChangeEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerItemDamageEvent;
|
||||
import org.bukkit.event.player.PlayerItemMendEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.player.PlayerRiptideEvent;
|
||||
import org.bukkit.event.player.PlayerVelocityEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
final class BukkitElytraObserver implements Listener {
|
||||
private final Server server;
|
||||
private final MovementMonitor monitor;
|
||||
private final Supplier<SentinelSettings> settings;
|
||||
private final LongSupplier nanoTime;
|
||||
private final Function<ItemStack, ItemContext> itemContext;
|
||||
private final Map<UUID, State> players = new HashMap<>();
|
||||
|
||||
BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
|
||||
LongSupplier nanoTime) {
|
||||
this(server, monitor, settings, nanoTime, BukkitElytraObserver::itemContext);
|
||||
}
|
||||
|
||||
BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
|
||||
LongSupplier nanoTime, Function<ItemStack, ItemContext> itemContext) {
|
||||
this.server = server;
|
||||
this.monitor = monitor;
|
||||
this.settings = settings;
|
||||
this.nanoTime = nanoTime;
|
||||
this.itemContext = itemContext;
|
||||
}
|
||||
|
||||
void sample() {
|
||||
int tick = server.getCurrentTick();
|
||||
long observedAtNanos = nanoTime.getAsLong();
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
var state = players.computeIfAbsent(player.getUniqueId(), ignored -> new State());
|
||||
if (player.getInventory() == null) {
|
||||
reset(player.getUniqueId());
|
||||
continue;
|
||||
}
|
||||
ItemStack chest = player.getInventory().getChestplate();
|
||||
if (!player.isGliding() || chest == null || chest.getType() != Material.ELYTRA) {
|
||||
reset(player.getUniqueId());
|
||||
continue;
|
||||
}
|
||||
Location current = player.getLocation();
|
||||
if (current == null || current.getWorld() == null) {
|
||||
reset(player.getUniqueId());
|
||||
continue;
|
||||
}
|
||||
if (state.elytraId == null) state.elytraId = UUID.randomUUID();
|
||||
Location previous = state.location;
|
||||
ExclusionReason reason = ExclusionReason.NONE;
|
||||
if (previous == null || previous.getWorld() == null || !previous.getWorld().equals(current.getWorld())) {
|
||||
previous = current;
|
||||
reason = ExclusionReason.LIFECYCLE;
|
||||
}
|
||||
state.glideTicks++;
|
||||
boolean opportunity = state.glideTicks % 20 == 0;
|
||||
ItemContext item = itemContext.apply(chest);
|
||||
monitor.record(player.getUniqueId(), new ElytraFrame(tick, observedAtNanos,
|
||||
current.getWorld().getUID(), state.elytraId, true,
|
||||
previous.getX(), previous.getY(), previous.getZ(), current.getX(), current.getY(), current.getZ(),
|
||||
state.boost, state.riptide, state.velocity, state.explosion, opportunity,
|
||||
state.wear, state.repair || state.experience, item.unbreaking(), item.unbreakable(), reason), settings.get());
|
||||
state.location = current.clone();
|
||||
state.clearSignals();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
if (event.getItem() != null && event.getItem().getType() == Material.FIREWORK_ROCKET
|
||||
&& event.getPlayer().isGliding()) state(event.getPlayer()).boost = true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onRiptide(PlayerRiptideEvent event) { state(event.getPlayer()).riptide = true; }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onVelocity(PlayerVelocityEvent event) { state(event.getPlayer()).velocity = true; }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageEvent event) {
|
||||
if (event.getEntity() instanceof Player player
|
||||
&& (event.getCause() == EntityDamageEvent.DamageCause.BLOCK_EXPLOSION
|
||||
|| event.getCause() == EntityDamageEvent.DamageCause.ENTITY_EXPLOSION)) {
|
||||
state(player).explosion = true;
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onWear(PlayerItemDamageEvent event) {
|
||||
if (event.getItem().getType() == Material.ELYTRA && event.getDamage() > 0) state(event.getPlayer()).wear = true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMend(PlayerItemMendEvent event) {
|
||||
if (event.getItem().getType() == Material.ELYTRA) state(event.getPlayer()).repair = true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onExperience(PlayerExpChangeEvent event) {
|
||||
if (event.getAmount() > 0) state(event.getPlayer()).experience = true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onArmorChange(PlayerArmorChangeEvent event) {
|
||||
if (event.getSlotType() == PlayerArmorChangeEvent.SlotType.CHEST) reset(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onToggleGlide(EntityToggleGlideEvent event) {
|
||||
if (event.getEntity() instanceof Player player) reset(player.getUniqueId());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) { reset(event.getPlayer().getUniqueId()); }
|
||||
|
||||
void clear() { players.clear(); }
|
||||
int trackedPlayerCount() { return players.size(); }
|
||||
|
||||
private static ItemContext itemContext(ItemStack item) {
|
||||
int unbreaking = item.getEnchantmentLevel(Enchantment.UNBREAKING);
|
||||
boolean unbreakable = item.getItemMeta() != null && item.getItemMeta().isUnbreakable();
|
||||
return new ItemContext(unbreaking, unbreakable);
|
||||
}
|
||||
|
||||
record ItemContext(int unbreaking, boolean unbreakable) {}
|
||||
|
||||
private State state(Player player) {
|
||||
return players.computeIfAbsent(player.getUniqueId(), ignored -> new State());
|
||||
}
|
||||
|
||||
private void reset(UUID playerId) {
|
||||
players.remove(playerId);
|
||||
monitor.forgetElytra(playerId);
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private UUID elytraId;
|
||||
private Location location;
|
||||
private int glideTicks;
|
||||
private boolean boost;
|
||||
private boolean riptide;
|
||||
private boolean velocity;
|
||||
private boolean explosion;
|
||||
private boolean wear;
|
||||
private boolean repair;
|
||||
private boolean experience;
|
||||
|
||||
private void clearSignals() {
|
||||
boost = riptide = velocity = explosion = wear = repair = experience = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,8 +2,14 @@ package games.dmg.purpursentinel;
|
||||
|
||||
public enum DetectionSignal {
|
||||
ABSOLUTE_GROUND_SPEED,
|
||||
CONTEXTUAL_GROUND_SPEED,
|
||||
UNAUTHORIZED_FLIGHT_STATE,
|
||||
UNSUPPORTED_ASCENT,
|
||||
UNSUPPORTED_HOVER,
|
||||
UNSUPPORTED_CRUISE
|
||||
UNSUPPORTED_CRUISE,
|
||||
UNSUPPORTED_BOAT_ASCENT,
|
||||
UNSUPPORTED_BOAT_HOVER,
|
||||
UNSUPPORTED_BOAT_CRUISE,
|
||||
ELYTRA_SOURCELESS_PROPULSION,
|
||||
ELYTRA_IMPLAUSIBLE_WEAR
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class ElytraDetector {
|
||||
private static final int SOURCELESS_TICKS_TO_ALERT = 6;
|
||||
private static final double ASCENT_PER_TICK = 0.10;
|
||||
private static final double ACCELERATION_PER_TICK = 0.12;
|
||||
private final Map<UUID, State> players = new HashMap<>();
|
||||
|
||||
Optional<AlertDecision> observe(UUID playerId, ElytraFrame frame, SentinelSettings settings) {
|
||||
var state = players.computeIfAbsent(playerId, ignored -> new State());
|
||||
state.expire(frame.observedAtNanos());
|
||||
if (!eligible(frame) || !frame.elytraId().equals(state.elytraId) && state.elytraId != null) {
|
||||
state.reset(frame.elytraId());
|
||||
state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE
|
||||
? ExclusionReason.LIFECYCLE : frame.exclusionReason();
|
||||
return Optional.empty();
|
||||
}
|
||||
if (state.elytraId == null) state.reset(frame.elytraId());
|
||||
if (state.world != null && (!state.world.equals(frame.world()) || state.lastTick + 1 != frame.tick())) {
|
||||
state.reset(frame.elytraId());
|
||||
}
|
||||
state.world = frame.world();
|
||||
state.lastTick = frame.tick();
|
||||
state.currentExclusion = ExclusionReason.NONE;
|
||||
if (state.firstObservedAtNanos == 0) state.firstObservedAtNanos = frame.observedAtNanos();
|
||||
state.eligibleTicks++;
|
||||
|
||||
double horizontalSpeed = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
|
||||
double acceleration = horizontalSpeed - state.lastHorizontalSpeed;
|
||||
double vertical = frame.toY() - frame.fromY();
|
||||
state.ascent += Math.max(0, vertical);
|
||||
state.peakAcceleration = Math.max(state.peakAcceleration, acceleration);
|
||||
boolean source = frame.observedBoost() || frame.riptide() || frame.externalVelocity() || frame.explosion();
|
||||
if (source) state.propulsionGraceThroughTick = frame.tick() + 20;
|
||||
if (frame.observedBoost()) state.observedBoosts++;
|
||||
if (frame.tick() <= state.propulsionGraceThroughTick || vertical < -0.05) {
|
||||
state.sourcelessTicks = 0;
|
||||
} else if (vertical > ASCENT_PER_TICK || acceleration > ACCELERATION_PER_TICK) {
|
||||
state.sourcelessTicks++;
|
||||
}
|
||||
state.lastHorizontalSpeed = horizontalSpeed;
|
||||
|
||||
if (frame.unbreakable() || frame.repairObserved() || frame.wearObserved()) {
|
||||
state.wearOpportunities = 0;
|
||||
} else if (frame.wearOpportunity()) {
|
||||
state.wearOpportunities++;
|
||||
}
|
||||
state.unbreakingLevel = frame.unbreakingLevel();
|
||||
|
||||
DetectionSignal signal = null;
|
||||
if (state.sourcelessTicks >= SOURCELESS_TICKS_TO_ALERT) {
|
||||
signal = DetectionSignal.ELYTRA_SOURCELESS_PROPULSION;
|
||||
} else if (!frame.unbreakable() && state.wearOpportunities >= wearThreshold(frame.unbreakingLevel())) {
|
||||
signal = DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR;
|
||||
}
|
||||
if (signal == null) return Optional.empty();
|
||||
state.signal = signal;
|
||||
state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds());
|
||||
state.lastEvidence = state.evidence(frame.observedAtNanos());
|
||||
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.elytra(playerId, state.lastEvidence));
|
||||
}
|
||||
|
||||
Optional<ElytraEvidence> inspect(UUID playerId, long nowNanos) {
|
||||
var state = players.get(playerId);
|
||||
if (state == null) return Optional.empty();
|
||||
state.expire(nowNanos);
|
||||
return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos));
|
||||
}
|
||||
|
||||
void forget(UUID playerId) { players.remove(playerId); }
|
||||
void clear() { players.clear(); }
|
||||
void cleanup(long nowNanos) {
|
||||
players.entrySet().removeIf(entry -> {
|
||||
var state = entry.getValue();
|
||||
state.expire(nowNanos);
|
||||
return state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos && !state.active();
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean eligible(ElytraFrame frame) {
|
||||
return frame.gliding() && frame.exclusionReason() == ExclusionReason.NONE
|
||||
&& finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ());
|
||||
}
|
||||
private static int wearThreshold(int unbreakingLevel) { return 60 * Math.max(1, unbreakingLevel + 1); }
|
||||
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 UUID elytraId;
|
||||
private int lastTick = Integer.MIN_VALUE;
|
||||
private long firstObservedAtNanos;
|
||||
private long evidenceExpiresAtNanos;
|
||||
private long cooldownUntilNanos;
|
||||
private int eligibleTicks;
|
||||
private int sourcelessTicks;
|
||||
private int observedBoosts;
|
||||
private int wearOpportunities;
|
||||
private int unbreakingLevel;
|
||||
private int propulsionGraceThroughTick = Integer.MIN_VALUE;
|
||||
private double ascent;
|
||||
private double peakAcceleration;
|
||||
private double lastHorizontalSpeed;
|
||||
private DetectionSignal signal = DetectionSignal.ELYTRA_SOURCELESS_PROPULSION;
|
||||
private ExclusionReason currentExclusion = ExclusionReason.NONE;
|
||||
private ElytraEvidence lastEvidence;
|
||||
|
||||
private boolean active() { return world != null && eligibleTicks > 0; }
|
||||
private void reset(UUID replacement) {
|
||||
world = null; elytraId = replacement; lastTick = Integer.MIN_VALUE; firstObservedAtNanos = 0;
|
||||
eligibleTicks = sourcelessTicks = observedBoosts = wearOpportunities = unbreakingLevel = 0;
|
||||
propulsionGraceThroughTick = Integer.MIN_VALUE;
|
||||
ascent = peakAcceleration = lastHorizontalSpeed = 0;
|
||||
}
|
||||
private void expire(long nowNanos) {
|
||||
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
|
||||
}
|
||||
private ElytraEvidence evidence(long nowNanos) {
|
||||
return new ElytraEvidence(signal, eligibleTicks, sourcelessTicks, observedBoosts, wearOpportunities,
|
||||
unbreakingLevel, ascent, peakAcceleration,
|
||||
Math.max(0, nowNanos - firstObservedAtNanos) / 1_000_000_000.0,
|
||||
Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0, currentExclusion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
public record ElytraEvidence(
|
||||
DetectionSignal signal,
|
||||
int eligibleTicks,
|
||||
int sourcelessTicks,
|
||||
int observedBoosts,
|
||||
int wearOpportunities,
|
||||
int unbreakingLevel,
|
||||
double ascent,
|
||||
double peakAcceleration,
|
||||
double evidenceAgeSeconds,
|
||||
double cooldownRemainingSeconds,
|
||||
ExclusionReason currentExclusion) {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record ElytraFrame(
|
||||
int tick,
|
||||
long observedAtNanos,
|
||||
UUID world,
|
||||
UUID elytraId,
|
||||
boolean gliding,
|
||||
double fromX,
|
||||
double fromY,
|
||||
double fromZ,
|
||||
double toX,
|
||||
double toY,
|
||||
double toZ,
|
||||
boolean observedBoost,
|
||||
boolean riptide,
|
||||
boolean externalVelocity,
|
||||
boolean explosion,
|
||||
boolean wearOpportunity,
|
||||
boolean wearObserved,
|
||||
boolean repairObserved,
|
||||
int unbreakingLevel,
|
||||
boolean unbreakable,
|
||||
ExclusionReason exclusionReason) {
|
||||
}
|
||||
@@ -106,7 +106,9 @@ 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, UNSUPPORTED_BOAT_ASCENT,
|
||||
UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE, ELYTRA_SOURCELESS_PROPULSION,
|
||||
ELYTRA_IMPLAUSIBLE_WEAR -> 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,27 @@ public record InspectionSnapshot(
|
||||
double capturedLimit,
|
||||
ExclusionReason currentExclusion,
|
||||
double cooldownRemainingSeconds,
|
||||
FlightEvidence flightEvidence) {
|
||||
FlightEvidence flightEvidence,
|
||||
GroundSpeedEvidence contextualEvidence,
|
||||
BoatEvidence boatEvidence,
|
||||
ElytraEvidence elytraEvidence) {
|
||||
|
||||
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, 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, null, null);
|
||||
}
|
||||
|
||||
public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit,
|
||||
ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence,
|
||||
GroundSpeedEvidence contextualEvidence) {
|
||||
this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit,
|
||||
currentExclusion, cooldownRemainingSeconds, flightEvidence, contextualEvidence, null, 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;
|
||||
|
||||
@@ -45,6 +45,27 @@ final class MovementEnvironment {
|
||||
return SupportState.UNSUPPORTED;
|
||||
}
|
||||
|
||||
static SupportState boatSupportState(Location location) {
|
||||
var world = location.getWorld();
|
||||
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
return SupportState.UNKNOWN;
|
||||
}
|
||||
int y = (int) Math.floor(location.getY() - 0.01);
|
||||
boolean supported = false;
|
||||
for (int x = location.getBlockX() - 1; x <= location.getBlockX() + 1; x++) {
|
||||
for (int z = location.getBlockZ() - 1; z <= location.getBlockZ() + 1; z++) {
|
||||
var block = world.getBlockAt(x, y, z);
|
||||
var type = block.getType();
|
||||
if (ALTERING.contains(type)) return SupportState.UNKNOWN;
|
||||
if (block.isLiquid() || block.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged()
|
||||
|| isIce(type) || type != Material.AIR && !block.isPassable()) {
|
||||
supported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return supported ? SupportState.SUPPORTED : SupportState.UNSUPPORTED;
|
||||
}
|
||||
|
||||
static boolean isOrdinary(Location location) {
|
||||
var world = location.getWorld();
|
||||
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ 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 BoatDetector boatDetector = new BoatDetector();
|
||||
private final ElytraDetector elytraDetector = new ElytraDetector();
|
||||
private final Map<UUID, TickBucket> pending = new HashMap<>();
|
||||
private final List<AlertDecision> flightAlerts = new ArrayList<>();
|
||||
|
||||
@@ -22,6 +24,14 @@ public final class MovementMonitor {
|
||||
flightDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add);
|
||||
}
|
||||
|
||||
public void record(UUID playerId, ElytraFrame frame, SentinelSettings settings) {
|
||||
elytraDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add);
|
||||
}
|
||||
|
||||
public void record(BoatFrame frame, SentinelSettings settings) {
|
||||
boatDetector.observe(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()) {
|
||||
@@ -49,6 +59,8 @@ public final class MovementMonitor {
|
||||
}
|
||||
detector.cleanup(currentTick, nowNanos);
|
||||
flightDetector.cleanup(currentTick, nowNanos);
|
||||
boatDetector.cleanup(currentTick, nowNanos);
|
||||
elytraDetector.cleanup(nowNanos);
|
||||
return List.copyOf(alerts);
|
||||
}
|
||||
|
||||
@@ -61,30 +73,45 @@ public final class MovementMonitor {
|
||||
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
|
||||
var movement = detector.inspect(playerId, nowNanos);
|
||||
var flight = flightDetector.inspect(playerId, nowNanos);
|
||||
if (flight.isEmpty()) return movement;
|
||||
var boat = boatDetector.inspect(playerId, nowNanos);
|
||||
var elytra = elytraDetector.inspect(playerId, nowNanos);
|
||||
if (flight.isEmpty() && boat.isEmpty() && elytra.isEmpty()) return movement;
|
||||
if (movement.isEmpty()) {
|
||||
return Optional.of(new InspectionSnapshot(0, 0, 0, 0,
|
||||
ExclusionReason.NONE, 0, flight.get()));
|
||||
ExclusionReason.NONE, 0, flight.orElse(null), null, boat.orElse(null), elytra.orElse(null)));
|
||||
}
|
||||
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.orElse(null), evidence.contextualEvidence(),
|
||||
boat.orElse(null), elytra.orElse(null)));
|
||||
}
|
||||
|
||||
public void forget(UUID playerId) {
|
||||
pending.remove(playerId);
|
||||
detector.forget(playerId);
|
||||
flightDetector.forget(playerId);
|
||||
boatDetector.forget(playerId);
|
||||
elytraDetector.forget(playerId);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
pending.clear();
|
||||
detector.clear();
|
||||
flightDetector.clear();
|
||||
boatDetector.clear();
|
||||
elytraDetector.clear();
|
||||
flightAlerts.clear();
|
||||
}
|
||||
|
||||
public void forgetElytra(UUID playerId) {
|
||||
elytraDetector.forget(playerId);
|
||||
}
|
||||
|
||||
public void releaseBoat(UUID boatId) {
|
||||
boatDetector.release(boatId);
|
||||
}
|
||||
|
||||
int trackedPlayerCount() {
|
||||
return detector.trackedPlayerCount();
|
||||
}
|
||||
@@ -96,7 +123,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 +134,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 +153,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 +167,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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import org.bukkit.scheduler.BukkitTask;
|
||||
public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
private MovementMonitor monitor;
|
||||
private BukkitMovementListener listener;
|
||||
private BukkitBoatObserver boatObserver;
|
||||
private BukkitElytraObserver elytraObserver;
|
||||
private BukkitTask tickTask;
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -20,14 +22,20 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
var settings = SettingsService.load(new AtomicYamlSettingsStore(configPath));
|
||||
monitor = new MovementMonitor(new MovementDetector());
|
||||
listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime);
|
||||
boatObserver = new BukkitBoatObserver(getServer(), monitor, settings::current, System::nanoTime);
|
||||
elytraObserver = new BukkitElytraObserver(getServer(), monitor, settings::current, System::nanoTime);
|
||||
var command = Objects.requireNonNull(getCommand("sentinel"), "Missing sentinel command");
|
||||
var handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime);
|
||||
command.setExecutor(handler);
|
||||
command.setTabCompleter(handler);
|
||||
getServer().getPluginManager().registerEvents(listener, this);
|
||||
getServer().getPluginManager().registerEvents(boatObserver, this);
|
||||
getServer().getPluginManager().registerEvents(elytraObserver, this);
|
||||
var alerts = new SentinelAlerts(getServer());
|
||||
tickTask = getServer().getScheduler().runTaskTimer(this, () -> {
|
||||
listener.sampleFlight();
|
||||
boatObserver.sample();
|
||||
elytraObserver.sample();
|
||||
monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send);
|
||||
}, 1L, 1L);
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
@@ -46,6 +54,16 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
HandlerList.unregisterAll(listener);
|
||||
listener = null;
|
||||
}
|
||||
if (boatObserver != null) {
|
||||
boatObserver.clear();
|
||||
HandlerList.unregisterAll(boatObserver);
|
||||
boatObserver = null;
|
||||
}
|
||||
if (elytraObserver != null) {
|
||||
elytraObserver.clear();
|
||||
HandlerList.unregisterAll(elytraObserver);
|
||||
elytraObserver = null;
|
||||
}
|
||||
if (monitor != null) {
|
||||
monitor.clear();
|
||||
monitor = null;
|
||||
|
||||
@@ -16,8 +16,26 @@ 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 if (alert.signal() == DetectionSignal.ELYTRA_SOURCELESS_PROPULSION
|
||||
|| alert.signal() == DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR) {
|
||||
message = String.format(Locale.ROOT,
|
||||
"[Sentinel] %s: %s; %d eligible ticks; ascent %.2f blocks; peak acceleration %.2f; observed boosts %d; wear opportunities %d; Unbreaking %.0f. Use /sentinel inspect %s.",
|
||||
subject.getName(), alert.signal(), alert.eligibleTicks(), alert.verticalDisplacement(),
|
||||
alert.peakSpeed(), alert.evidenceWindowSeconds(), alert.breachCount(), alert.capturedLimit(),
|
||||
subject.getName());
|
||||
} else if (alert.vehicleType() != null) {
|
||||
message = String.format(Locale.ROOT,
|
||||
"[Sentinel] %s: %s; boat %s; %d unsupported ticks; %.2f horizontal blocks; %.2f ascent blocks. Use /sentinel inspect %s.",
|
||||
subject.getName(), alert.signal(), alert.vehicleType(), alert.eligibleTicks(),
|
||||
alert.horizontalDistance(), alert.verticalDisplacement(), subject.getName());
|
||||
} else {
|
||||
message = String.format(Locale.ROOT,
|
||||
"[Sentinel] %s: %s; %d unsupported ticks; %.2f horizontal blocks; %.2f vertical blocks. Use /sentinel inspect %s.",
|
||||
|
||||
@@ -93,7 +93,24 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
var evidence = snapshot.get();
|
||||
if (evidence.flightEvidence() != null) {
|
||||
if (evidence.elytraEvidence() != null) {
|
||||
var elytra = evidence.elytraEvidence();
|
||||
sender.sendMessage(String.format(Locale.ROOT,
|
||||
"%s: %s; %d eligible ticks; %d sourceless ticks; ascent %.2f blocks; peak acceleration %.2f; observed boosts %d; wear opportunities %d; Unbreaking %d; age %.1fs; exclusion %s; cooldown %.1fs.",
|
||||
player.get().getName(), elytra.signal(), elytra.eligibleTicks(), elytra.sourcelessTicks(),
|
||||
elytra.ascent(), elytra.peakAcceleration(), elytra.observedBoosts(),
|
||||
elytra.wearOpportunities(), elytra.unbreakingLevel(), elytra.evidenceAgeSeconds(),
|
||||
elytra.currentExclusion().description(), elytra.cooldownRemainingSeconds()));
|
||||
} else if (evidence.boatEvidence() != null) {
|
||||
var boat = evidence.boatEvidence();
|
||||
sender.sendMessage(String.format(Locale.ROOT,
|
||||
"%s: %s; boat %s; supported %s; %d eligible ticks; signal counts ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.",
|
||||
player.get().getName(), boat.signal(), boat.vehicleType(),
|
||||
yesNo(boat.supportState() == SupportState.SUPPORTED), boat.eligibleTicks(),
|
||||
boat.ascentTicks(), boat.hoverTicks(), boat.cruiseTicks(), boat.horizontalDistance(),
|
||||
boat.verticalDisplacement(), boat.evidenceAgeSeconds(), boat.currentExclusion().description(),
|
||||
boat.cooldownRemainingSeconds()));
|
||||
} else 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.",
|
||||
@@ -102,11 +119,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,29 @@ public record SentinelSettings(
|
||||
int unsupportedFlightTicks,
|
||||
int unsupportedHoverTicks,
|
||||
double unsupportedAscentBlocks,
|
||||
double unsupportedCruiseBlocks) {
|
||||
double unsupportedCruiseBlocks,
|
||||
double contextualSpeedRatio,
|
||||
double contextualMinimumExcess,
|
||||
int unsupportedBoatTicks,
|
||||
int unsupportedBoatHoverTicks,
|
||||
double unsupportedBoatAscentBlocks,
|
||||
double unsupportedBoatCruiseBlocks) {
|
||||
|
||||
public SentinelSettings(double horizontalSpeedLimit, int breachesToAlert, int evidenceWindowSeconds,
|
||||
int alertCooldownSeconds, double minimumTps, int exemptionGraceTicks,
|
||||
int unsupportedFlightTicks, int unsupportedHoverTicks, double unsupportedAscentBlocks,
|
||||
double unsupportedCruiseBlocks, double contextualSpeedRatio, double contextualMinimumExcess) {
|
||||
this(horizontalSpeedLimit, breachesToAlert, evidenceWindowSeconds, alertCooldownSeconds,
|
||||
minimumTps, exemptionGraceTicks, unsupportedFlightTicks, unsupportedHoverTicks,
|
||||
unsupportedAscentBlocks, unsupportedCruiseBlocks, contextualSpeedRatio, contextualMinimumExcess,
|
||||
40, 30, 2.0, 12.0);
|
||||
}
|
||||
|
||||
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,
|
||||
40, 30, 2.0, 12.0);
|
||||
}
|
||||
|
||||
public SentinelSettings {
|
||||
@@ -29,10 +46,17 @@ 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");
|
||||
requireRange(unsupportedBoatTicks, 10, 200, "unsupported-boat-ticks");
|
||||
requireRange(unsupportedBoatHoverTicks, 10, 200, "unsupported-boat-hover-ticks");
|
||||
requireFiniteRange(unsupportedBoatAscentBlocks, 0.5, 20.0, "unsupported-boat-ascent-blocks");
|
||||
requireFiniteRange(unsupportedBoatCruiseBlocks, 2.0, 100.0, "unsupported-boat-cruise-blocks");
|
||||
}
|
||||
|
||||
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,
|
||||
40, 30, 2.0, 12.0);
|
||||
}
|
||||
|
||||
private static void requireRange(int value, int minimum, int maximum, String key) {
|
||||
|
||||
@@ -14,7 +14,13 @@ 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),
|
||||
UNSUPPORTED_BOAT_TICKS("unsupported-boat-ticks", "10", "200", true),
|
||||
UNSUPPORTED_BOAT_HOVER_TICKS("unsupported-boat-hover-ticks", "10", "200", true),
|
||||
UNSUPPORTED_BOAT_ASCENT_BLOCKS("unsupported-boat-ascent-blocks", "0.5", "20.0", false),
|
||||
UNSUPPORTED_BOAT_CRUISE_BLOCKS("unsupported-boat-cruise-blocks", "2.0", "100.0", false);
|
||||
|
||||
private final String key;
|
||||
private final String minimum;
|
||||
|
||||
@@ -47,7 +47,13 @@ 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),
|
||||
optionalInteger(values, SettingKey.UNSUPPORTED_BOAT_TICKS, 40),
|
||||
optionalInteger(values, SettingKey.UNSUPPORTED_BOAT_HOVER_TICKS, 30),
|
||||
optionalDecimal(values, SettingKey.UNSUPPORTED_BOAT_ASCENT_BLOCKS, 2.0),
|
||||
optionalDecimal(values, SettingKey.UNSUPPORTED_BOAT_CRUISE_BLOCKS, 12.0));
|
||||
}
|
||||
|
||||
private static Object parseValue(SettingKey key, String value) {
|
||||
@@ -108,6 +114,12 @@ 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);
|
||||
case UNSUPPORTED_BOAT_TICKS -> Integer.toString(settings.unsupportedBoatTicks());
|
||||
case UNSUPPORTED_BOAT_HOVER_TICKS -> Integer.toString(settings.unsupportedBoatHoverTicks());
|
||||
case UNSUPPORTED_BOAT_ASCENT_BLOCKS -> decimal(settings.unsupportedBoatAscentBlocks(), 1);
|
||||
case UNSUPPORTED_BOAT_CRUISE_BLOCKS -> decimal(settings.unsupportedBoatCruiseBlocks(), 1);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,9 @@ 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
|
||||
unsupported-boat-ticks: 40
|
||||
unsupported-boat-hover-ticks: 30
|
||||
unsupported-boat-ascent-blocks: 2.0
|
||||
unsupported-boat-cruise-blocks: 12.0
|
||||
@@ -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;
|
||||
@@ -43,6 +45,7 @@ class PluginLifecycleTest {
|
||||
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
|
||||
var currentTick = new java.util.concurrent.atomic.AtomicInteger(1);
|
||||
when(server.getCurrentTick()).thenAnswer(call -> currentTick.get());
|
||||
when(server.getWorlds()).thenReturn(List.of());
|
||||
var scheduler = mock(BukkitScheduler.class);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
var manager = mock(PluginManager.class);
|
||||
@@ -90,6 +93,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);
|
||||
@@ -105,7 +112,11 @@ class PluginLifecycleTest {
|
||||
assertNotNull(tick.get());
|
||||
assertInstanceOf(SentinelCommand.class, command.getExecutor());
|
||||
assertSame(command.getExecutor(), command.getTabCompleter());
|
||||
assertTrue(Files.readString(directory.toPath().resolve("config.yml")).contains("breaches-to-alert: 6"));
|
||||
String config = Files.readString(directory.toPath().resolve("config.yml"));
|
||||
assertTrue(config.contains("breaches-to-alert: 6"));
|
||||
assertTrue(config.contains("unsupported-boat-ticks: 40"));
|
||||
verify(manager).registerEvents(isA(BukkitBoatObserver.class), eq(plugin));
|
||||
verify(manager).registerEvents(isA(BukkitElytraObserver.class), eq(plugin));
|
||||
for (int sample = 1; sample <= 6; sample++) {
|
||||
currentTick.set(sample);
|
||||
events.call(new PlayerMoveEvent(subject, new Location(world, 0, 64, 0), new Location(world, 1, 64, 0)));
|
||||
@@ -114,7 +125,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 +145,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);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BoatMonitorTest {
|
||||
private static final UUID WORLD = UUID.randomUUID();
|
||||
private static final UUID BOAT = UUID.randomUUID();
|
||||
private static final UUID PLAYER = UUID.randomUUID();
|
||||
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
|
||||
|
||||
@Test void sustainedHoverAlertsCurrentControllerAndExposesBoundedEvidence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
for (int tick = 1; tick <= 30; tick++) {
|
||||
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
|
||||
"BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), SETTINGS);
|
||||
}
|
||||
|
||||
var alert = monitor.finishBefore(31, nanos(31)).getFirst();
|
||||
assertEquals(PLAYER, alert.playerId());
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_HOVER, alert.signal());
|
||||
assertEquals("BAMBOO_RAFT", alert.vehicleType());
|
||||
assertEquals(30, alert.eligibleTicks());
|
||||
var evidence = monitor.inspect(PLAYER, nanos(31)).orElseThrow().boatEvidence();
|
||||
assertEquals(BOAT, evidence.vehicleId());
|
||||
assertEquals(SupportState.UNSUPPORTED, evidence.supportState());
|
||||
assertEquals(30, evidence.hoverTicks());
|
||||
}
|
||||
|
||||
@Test void ascentAndLevelTravelHaveIndependentPersistenceThresholds() {
|
||||
var ascent = new MovementMonitor(new MovementDetector());
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
ascent.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
|
||||
"OAK_BOAT", 0, y, 0, 0, y + 0.05, 0), SETTINGS);
|
||||
y += 0.05;
|
||||
}
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_ASCENT,
|
||||
ascent.finishBefore(41, nanos(41)).getFirst().signal());
|
||||
|
||||
var cruise = new MovementMonitor(new MovementDetector());
|
||||
double x = 0;
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
cruise.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
|
||||
"CHERRY_CHEST_BOAT", x, 80, 0, x + 0.3, 80, 0), SETTINGS);
|
||||
x += 0.3;
|
||||
}
|
||||
var alert = cruise.finishBefore(41, nanos(41)).getFirst();
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, alert.signal());
|
||||
assertEquals(12.0, alert.horizontalDistance(), 1e-9);
|
||||
}
|
||||
|
||||
@Test void ascentStillRequiresPersistenceEvenWhenDisplacementThresholdIsReachedEarly() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 10; tick++) {
|
||||
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
|
||||
"OAK_BOAT", 0, y, 0, 0, y + 0.25, 0), SETTINGS);
|
||||
y += 0.25;
|
||||
}
|
||||
assertTrue(monitor.finishBefore(11, nanos(11)).isEmpty());
|
||||
}
|
||||
|
||||
@Test void ordinaryFallSupportUnknownGeometryAndOneSpikeNeverAlert() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
double y = 90;
|
||||
for (int tick = 1; tick <= 50; tick++) {
|
||||
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
|
||||
"OAK_BOAT", 0, y, 0, 0, y - 0.2, 0), SETTINGS);
|
||||
y -= 0.2;
|
||||
}
|
||||
monitor.record(new BoatFrame(51, nanos(51), BOAT, PLAYER, WORLD, "OAK_BOAT",
|
||||
0, y, 0, 0, y, 0, SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS);
|
||||
monitor.record(BoatFrame.unsupported(72, nanos(72), BOAT, PLAYER, WORLD,
|
||||
"OAK_BOAT", 0, y, 0, 2, y, 0), SETTINGS);
|
||||
assertTrue(monitor.finishBefore(73, nanos(73)).isEmpty());
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(73)).isEmpty());
|
||||
}
|
||||
|
||||
@Test void controllerChangesRemovalAndCleanupNeverAttributeFormerPassenger() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var replacement = UUID.randomUUID();
|
||||
for (int tick = 1; tick <= 29; tick++) {
|
||||
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
|
||||
"SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS);
|
||||
}
|
||||
monitor.releaseBoat(BOAT);
|
||||
for (int tick = 50; tick <= 79; tick++) {
|
||||
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, replacement, WORLD,
|
||||
"SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS);
|
||||
}
|
||||
var alert = monitor.finishBefore(80, nanos(80)).getFirst();
|
||||
assertEquals(replacement, alert.playerId());
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(80)).isEmpty());
|
||||
monitor.clear();
|
||||
assertTrue(monitor.inspect(replacement, nanos(80)).isEmpty());
|
||||
}
|
||||
|
||||
private static long nanos(int tick) { return tick * 50_000_000L; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 BoatSupportTest {
|
||||
@ParameterizedTest(name = "{0}") @MethodSource("supportMaterials")
|
||||
void waterIceAndCollidableTerrainSupportBoats(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);
|
||||
when(block.isPassable()).thenReturn(material == Material.WATER);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
|
||||
assertEquals(SupportState.SUPPORTED, MovementEnvironment.boatSupportState(new Location(world, 0.5, 64, 0.5)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test void pistonGeometryIsUnknownRatherThanEvidenceOrSupport() {
|
||||
var world = mock(World.class);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
var block = mock(Block.class);
|
||||
when(block.getType()).thenReturn(Material.MOVING_PISTON);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
|
||||
assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0)));
|
||||
}
|
||||
|
||||
@org.junit.jupiter.api.Test void unloadedChunksRemainUnknownWithoutBlockReads() {
|
||||
var world = mock(World.class);
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
|
||||
assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0)));
|
||||
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
private static Stream<Material> supportMaterials() {
|
||||
return Stream.of(Material.WATER, Material.ICE, Material.PACKED_ICE, Material.BLUE_ICE,
|
||||
Material.OAK_PLANKS, Material.STONE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Boat;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BukkitBoatObserverTest {
|
||||
@Test void everyPurpurBoatAndRaftTypeIsRecognized() {
|
||||
var names = java.util.Arrays.stream(EntityType.values()).map(Enum::name)
|
||||
.filter(name -> name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT")
|
||||
|| name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT"))
|
||||
.toList();
|
||||
assertFalse(names.isEmpty());
|
||||
names.forEach(name -> assertTrue(BukkitBoatObserver.isBoatFamily(EntityType.valueOf(name)), name));
|
||||
}
|
||||
|
||||
@Test void samplesServerBoatTrajectoryOncePerTickAndAttributesFirstPlayerPassenger() {
|
||||
var server = mock(Server.class);
|
||||
when(server.getCurrentTick()).thenReturn(1, 2);
|
||||
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var boat = mock(Boat.class);
|
||||
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(boat.getType()).thenReturn(EntityType.BAMBOO_RAFT);
|
||||
when(boat.isValid()).thenReturn(true);
|
||||
when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 0.2, 80, 0));
|
||||
var controller = mock(Player.class);
|
||||
when(controller.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(boat.getPassengers()).thenReturn(List.of(controller));
|
||||
when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat));
|
||||
when(server.getWorlds()).thenReturn(List.of(world));
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
|
||||
new AtomicLong(50_000_000L)::get, ignored -> SupportState.UNSUPPORTED);
|
||||
|
||||
observer.sample();
|
||||
observer.sample();
|
||||
|
||||
assertEquals(1, observer.trackedBoatCount());
|
||||
assertTrue(monitor.finishBefore(3, 150_000_000L).isEmpty());
|
||||
verify(boat, never()).setVelocity(any());
|
||||
verify(boat, never()).eject();
|
||||
verify(boat, never()).remove();
|
||||
}
|
||||
|
||||
@Test void lifecycleEventsReleaseBoatStateWithoutMutatingVehicle() {
|
||||
var server = mock(Server.class);
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
|
||||
System::nanoTime, ignored -> SupportState.UNSUPPORTED);
|
||||
var boat = mock(Boat.class);
|
||||
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
observer.onCollision(new org.bukkit.event.vehicle.VehicleBlockCollisionEvent(boat,
|
||||
mock(org.bukkit.block.Block.class)));
|
||||
observer.onTeleport(new org.bukkit.event.entity.EntityTeleportEvent(boat,
|
||||
mock(Location.class), mock(Location.class)));
|
||||
verify(boat, never()).eject();
|
||||
verify(boat, never()).remove();
|
||||
verify(boat, never()).teleport(any(Location.class));
|
||||
}
|
||||
|
||||
@Test void controllerLossAndInvalidBoatReleaseEvidence() {
|
||||
var server = mock(Server.class);
|
||||
when(server.getCurrentTick()).thenReturn(1, 2);
|
||||
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var boat = mock(Boat.class);
|
||||
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(boat.getType()).thenReturn(EntityType.OAK_BOAT);
|
||||
when(boat.isValid()).thenReturn(true, false);
|
||||
when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0));
|
||||
var controller = mock(Player.class);
|
||||
when(controller.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(boat.getPassengers()).thenReturn(List.of(controller));
|
||||
when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat));
|
||||
when(server.getWorlds()).thenReturn(List.of(world));
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
|
||||
System::nanoTime, ignored -> SupportState.UNSUPPORTED);
|
||||
observer.sample();
|
||||
observer.sample();
|
||||
assertEquals(0, observer.trackedBoatCount());
|
||||
assertTrue(monitor.inspect(controller.getUniqueId(), System.nanoTime()).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BukkitElytraObserverTest {
|
||||
@Test void samplesEquippedGlidingElytraAndCorrelatesObservedRocketUse() {
|
||||
var server = mock(Server.class);
|
||||
var tick = new AtomicInteger();
|
||||
when(server.getCurrentTick()).thenAnswer(call -> tick.incrementAndGet());
|
||||
var world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(player.isGliding()).thenReturn(true);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 1, 80.2, 0));
|
||||
var inventory = mock(PlayerInventory.class);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
var elytra = mock(ItemStack.class);
|
||||
when(elytra.getType()).thenReturn(Material.ELYTRA);
|
||||
var meta = mock(ItemMeta.class);
|
||||
when(elytra.getItemMeta()).thenReturn(meta);
|
||||
when(inventory.getChestplate()).thenReturn(elytra);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var observer = new BukkitElytraObserver(server, monitor, SentinelSettings::defaults, System::nanoTime,
|
||||
ignored -> new BukkitElytraObserver.ItemContext(0, false));
|
||||
var rocket = mock(ItemStack.class);
|
||||
when(rocket.getType()).thenReturn(Material.FIREWORK_ROCKET);
|
||||
var event = new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, rocket, null, null);
|
||||
|
||||
observer.onInteract(event);
|
||||
observer.sample();
|
||||
observer.sample();
|
||||
|
||||
assertEquals(1, observer.trackedPlayerCount());
|
||||
assertTrue(monitor.finishBefore(3, System.nanoTime()).isEmpty());
|
||||
verify(player, never()).setVelocity(any());
|
||||
verify(player, never()).setGliding(anyBoolean());
|
||||
verify(rocket, never()).setAmount(anyInt());
|
||||
}
|
||||
|
||||
@Test void glideInterruptionAndArmorSwapClearObserverAndDetectorState() {
|
||||
var server = mock(Server.class);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var observer = new BukkitElytraObserver(server, monitor, SentinelSettings::defaults, System::nanoTime,
|
||||
ignored -> new BukkitElytraObserver.ItemContext(0, false));
|
||||
observer.onToggleGlide(new org.bukkit.event.entity.EntityToggleGlideEvent(player, false));
|
||||
observer.onArmorChange(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(player,
|
||||
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST,
|
||||
mock(ItemStack.class), mock(ItemStack.class)));
|
||||
assertEquals(0, observer.trackedPlayerCount());
|
||||
assertTrue(monitor.inspect(player.getUniqueId(), System.nanoTime()).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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"));
|
||||
assertTrue(config.contains("unsupported-boat-ticks: 40"));
|
||||
assertTrue(config.contains("unsupported-boat-hover-ticks: 30"));
|
||||
assertTrue(config.contains("unsupported-boat-ascent-blocks: 2.0"));
|
||||
assertTrue(config.contains("unsupported-boat-cruise-blocks: 12.0"));
|
||||
var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class");
|
||||
assertNotNull(entry);
|
||||
try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ElytraMonitorTest {
|
||||
private static final UUID PLAYER = UUID.randomUUID();
|
||||
private static final UUID WORLD = UUID.randomUUID();
|
||||
private static final UUID ELYTRA = UUID.randomUUID();
|
||||
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
|
||||
|
||||
@Test void highHorizontalSpeedAndOrdinaryDescendingGlideNeverCreateEvidence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
double x = 0;
|
||||
double y = 120;
|
||||
for (int tick = 1; tick <= 100; tick++) {
|
||||
monitor.record(PLAYER, frame(tick, x, y, x + 2.5, y - 0.2, false, false,
|
||||
false, false, false, 0, tick % 20 == 0), SETTINGS);
|
||||
x += 2.5;
|
||||
y -= 0.2;
|
||||
}
|
||||
assertTrue(monitor.finishBefore(101, nanos(101)).isEmpty());
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(101)).isEmpty());
|
||||
}
|
||||
|
||||
@Test void repeatedSourcelessAscentAlertsWithExplainableEvidence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
double x = 0;
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 6; tick++) {
|
||||
monitor.record(PLAYER, frame(tick, x, y, x + 0.4 + tick * 0.2, y + 0.2,
|
||||
false, false, false, false, false, 0, false), SETTINGS);
|
||||
x += 0.4 + tick * 0.2;
|
||||
y += 0.2;
|
||||
}
|
||||
var alert = monitor.finishBefore(7, nanos(7)).getFirst();
|
||||
assertEquals(DetectionSignal.ELYTRA_SOURCELESS_PROPULSION, alert.signal());
|
||||
var evidence = monitor.inspect(PLAYER, nanos(7)).orElseThrow().elytraEvidence();
|
||||
assertEquals(6, evidence.sourcelessTicks());
|
||||
assertTrue(evidence.ascent() >= 1.2 - 1e-9);
|
||||
assertTrue(evidence.peakAcceleration() > 0.1);
|
||||
assertEquals(0, evidence.observedBoosts());
|
||||
}
|
||||
|
||||
@Test void rocketRiptideVelocityExplosionAndAltitudeConversionExcludePropulsion() {
|
||||
var contexts = new boolean[][] {
|
||||
{true, false, false, false},
|
||||
{false, true, false, false},
|
||||
{false, false, true, false},
|
||||
{false, false, false, true}
|
||||
};
|
||||
for (boolean[] context : contexts) {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
double x = 0;
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 20; tick++) {
|
||||
monitor.record(PLAYER, frame(tick, x, y, x + tick * 0.3, y + 0.2,
|
||||
context[0], context[1], context[2], context[3], false, 0, false), SETTINGS);
|
||||
x += tick * 0.3;
|
||||
y += 0.2;
|
||||
}
|
||||
assertTrue(monitor.finishBefore(21, nanos(21)).isEmpty());
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(21)).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void observedRocketCreatesCorrelationGraceWithoutTrustingCarriedInventory() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
double x = 0;
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 20; tick++) {
|
||||
monitor.record(PLAYER, frame(tick, x, y, x + tick * .3, y + .2,
|
||||
tick == 1, false, false, false, false, 0, false), SETTINGS);
|
||||
x += tick * .3;
|
||||
y += .2;
|
||||
}
|
||||
assertTrue(monitor.finishBefore(21, nanos(21)).isEmpty());
|
||||
}
|
||||
|
||||
@Test void wearRequiresSixtyEligibleOpportunitiesAndAccountsForUnbreaking() {
|
||||
var plain = new MovementMonitor(new MovementDetector());
|
||||
for (int opportunity = 1; opportunity <= 59; opportunity++) {
|
||||
plain.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
|
||||
}
|
||||
assertTrue(plain.finishBefore(60, nanos(60)).isEmpty());
|
||||
plain.record(PLAYER, wearFrame(60, 0, false, false, false), SETTINGS);
|
||||
assertEquals(DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR,
|
||||
plain.finishBefore(61, nanos(61)).getFirst().signal());
|
||||
|
||||
var unbreaking = new MovementMonitor(new MovementDetector());
|
||||
for (int opportunity = 1; opportunity <= 60; opportunity++) {
|
||||
unbreaking.record(PLAYER, wearFrame(opportunity, 3, false, false, false), SETTINGS);
|
||||
}
|
||||
assertTrue(unbreaking.finishBefore(61, nanos(61)).isEmpty());
|
||||
for (int opportunity = 61; opportunity <= 240; opportunity++) {
|
||||
unbreaking.record(PLAYER, wearFrame(opportunity, 3, false, false, false), SETTINGS);
|
||||
}
|
||||
assertEquals(DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR,
|
||||
unbreaking.finishBefore(241, nanos(241)).getFirst().signal());
|
||||
}
|
||||
|
||||
@Test void observedWearMendingXpUnbreakableSwapAndInterruptionResetWearEvidence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
for (int opportunity = 1; opportunity <= 59; opportunity++) {
|
||||
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
|
||||
}
|
||||
monitor.record(PLAYER, wearFrame(60, 0, true, false, false), SETTINGS);
|
||||
for (int opportunity = 61; opportunity <= 119; opportunity++) {
|
||||
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
|
||||
}
|
||||
monitor.record(PLAYER, wearFrame(120, 0, false, true, false), SETTINGS);
|
||||
for (int opportunity = 121; opportunity <= 179; opportunity++) {
|
||||
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
|
||||
}
|
||||
monitor.record(PLAYER, wearFrame(180, 0, false, false, true), SETTINGS);
|
||||
assertTrue(monitor.finishBefore(181, nanos(181)).isEmpty());
|
||||
|
||||
monitor.record(PLAYER, new ElytraFrame(181, nanos(181), WORLD, UUID.randomUUID(), false,
|
||||
0, 80, 0, 0, 80, 0, false, false, false, false,
|
||||
false, false, false, 0, false, ExclusionReason.NONE), SETTINGS);
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(181)).isEmpty());
|
||||
}
|
||||
|
||||
@Test void disconnectAndDisableClearAllElytraState() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
for (int opportunity = 1; opportunity <= 59; opportunity++) {
|
||||
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
|
||||
}
|
||||
monitor.forget(PLAYER);
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(60)).isEmpty());
|
||||
monitor.record(PLAYER, wearFrame(61, 0, false, false, false), SETTINGS);
|
||||
monitor.clear();
|
||||
assertTrue(monitor.inspect(PLAYER, nanos(62)).isEmpty());
|
||||
}
|
||||
|
||||
private static ElytraFrame wearFrame(int opportunity, int unbreaking, boolean wearObserved,
|
||||
boolean repairObserved, boolean unbreakable) {
|
||||
return frame(opportunity, 0, 80, 0.2, 79.9, false, false, false, false,
|
||||
unbreakable, unbreaking, true, wearObserved, repairObserved);
|
||||
}
|
||||
|
||||
private static ElytraFrame frame(int tick, double fromX, double fromY, double toX, double toY,
|
||||
boolean boost, boolean riptide, boolean velocity, boolean explosion, boolean unbreakable,
|
||||
int unbreaking, boolean wearOpportunity) {
|
||||
return frame(tick, fromX, fromY, toX, toY, boost, riptide, velocity, explosion,
|
||||
unbreakable, unbreaking, wearOpportunity, false, false);
|
||||
}
|
||||
|
||||
private static ElytraFrame frame(int tick, double fromX, double fromY, double toX, double toY,
|
||||
boolean boost, boolean riptide, boolean velocity, boolean explosion, boolean unbreakable,
|
||||
int unbreaking, boolean wearOpportunity, boolean wearObserved, boolean repairObserved) {
|
||||
return new ElytraFrame(tick, nanos(tick), WORLD, ELYTRA, true,
|
||||
fromX, fromY, 0, toX, toY, 0, boost, riptide, velocity, explosion,
|
||||
wearOpportunity, wearObserved, repairObserved, unbreaking, unbreakable,
|
||||
ExclusionReason.NONE);
|
||||
}
|
||||
|
||||
private static long nanos(int tick) { return tick * 50_000_000L; }
|
||||
}
|
||||
@@ -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,69 @@ 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 elytraAlertDistinguishesSignalAndReportsOnlyBoundedContext() {
|
||||
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();
|
||||
var evidence = new ElytraEvidence(DetectionSignal.ELYTRA_SOURCELESS_PROPULSION,
|
||||
20, 6, 1, 0, 3, 2.4, 0.35, 1, 60, ExclusionReason.NONE);
|
||||
new SentinelAlerts(server).send(AlertDecision.elytra(id, evidence));
|
||||
verify(operator).sendMessage(argThat((String message) -> message.contains("ELYTRA_SOURCELESS_PROPULSION")
|
||||
&& message.contains("ascent 2.40") && message.contains("peak acceleration 0.35")
|
||||
&& message.contains("observed boosts 1") && message.contains("Unbreaking 3")
|
||||
&& !message.contains("x=") && !message.toLowerCase().contains("inventory")
|
||||
&& !message.toLowerCase().contains("cheat")));
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void boatAlertNamesControllerVehicleSignalAndBoundedEvidenceWithoutCoordinates() {
|
||||
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();
|
||||
var evidence = new BoatEvidence(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, UUID.randomUUID(),
|
||||
"BAMBOO_RAFT", SupportState.UNSUPPORTED, 40, 0, 0, 40, 12, 0, 2, 60,
|
||||
ExclusionReason.NONE);
|
||||
new SentinelAlerts(server).send(AlertDecision.boat(id, evidence));
|
||||
verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_CRUISE")
|
||||
&& message.contains("BAMBOO_RAFT") && message.contains("40 unsupported ticks")
|
||||
&& message.contains("12.00 horizontal") && !message.contains("x=")
|
||||
&& !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");
|
||||
@@ -52,6 +71,48 @@ class SentinelCommandTest {
|
||||
verify(online, never()).teleport(any(org.bukkit.Location.class));
|
||||
}
|
||||
|
||||
@Test void inspectReportsBoundedElytraEvidenceAndExclusions() throws Exception {
|
||||
var fixture = fixture();
|
||||
var target = online(fixture.server, "Glider");
|
||||
var sender = permitted("sentinel.inspect");
|
||||
var world = UUID.randomUUID();
|
||||
var elytra = UUID.randomUUID();
|
||||
double x = 0;
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 6; tick++) {
|
||||
fixture.monitor.record(target.getUniqueId(), new ElytraFrame(tick, tick * 50_000_000L,
|
||||
world, elytra, true, x, y, 0, x + tick * .2, y + .2, 0,
|
||||
false, false, false, false, false, false, false, 2, false,
|
||||
ExclusionReason.NONE), fixture.settings.current());
|
||||
x += tick * .2;
|
||||
y += .2;
|
||||
}
|
||||
fixture.monitor.finishBefore(7, 350_000_000L);
|
||||
fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Glider"});
|
||||
verify(sender).sendMessage(argThat((String message) -> message.contains("ELYTRA_SOURCELESS_PROPULSION")
|
||||
&& message.contains("6 sourceless ticks") && message.contains("ascent 1.20")
|
||||
&& message.contains("Unbreaking 2") && message.contains("exclusion none")
|
||||
&& message.contains("cooldown") && !message.contains("80")));
|
||||
}
|
||||
|
||||
@Test void inspectReportsBoundedBoatEvidenceSupportExclusionAndCooldown() throws Exception {
|
||||
var fixture = fixture();
|
||||
var target = online(fixture.server, "Boater");
|
||||
var sender = permitted("sentinel.inspect");
|
||||
var boat = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
for (int tick = 1; tick <= 30; tick++) {
|
||||
fixture.monitor.record(BoatFrame.unsupported(tick, tick * 50_000_000L, boat,
|
||||
target.getUniqueId(), world, "BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), fixture.settings.current());
|
||||
}
|
||||
fixture.monitor.finishBefore(31, 1_550_000_000L);
|
||||
fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Boater"});
|
||||
verify(sender).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_HOVER")
|
||||
&& message.contains("BAMBOO_RAFT") && message.contains("supported no")
|
||||
&& message.contains("30 eligible ticks") && message.contains("exclusion none")
|
||||
&& message.contains("cooldown") && !message.contains("80")));
|
||||
}
|
||||
|
||||
@Test void inspectReportsBoundedFlightStateSignalsAgeAndCooldown() throws Exception {
|
||||
var fixture = fixture();
|
||||
var target = online(fixture.server, "Flyer");
|
||||
|
||||
@@ -91,6 +91,49 @@ 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 boatThresholdsDefaultLoadPersistAndRemainBackwardCompatible() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
AtomicYamlSettingsStore.writeDefaults(file);
|
||||
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
|
||||
assertEquals(40, service.current().unsupportedBoatTicks());
|
||||
assertEquals(30, service.current().unsupportedBoatHoverTicks());
|
||||
assertEquals(2.0, service.current().unsupportedBoatAscentBlocks(), 1e-9);
|
||||
assertEquals(12.0, service.current().unsupportedBoatCruiseBlocks(), 1e-9);
|
||||
assertEquals("50", service.update(SettingKey.UNSUPPORTED_BOAT_TICKS, "50"));
|
||||
assertEquals("15.0", service.update(SettingKey.UNSUPPORTED_BOAT_CRUISE_BLOCKS, "15"));
|
||||
assertEquals(service.current(), SettingsService.load(new AtomicYamlSettingsStore(file)).current());
|
||||
|
||||
var old = directory.resolve("pre-boat.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(40, compatible.current().unsupportedBoatTicks());
|
||||
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