feat(sentinel): detect unsupported boat travel #4
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, contextually impossible ground speed, and sustained unauthorized unsupported flight. Contextual ground-speed evidence uses server-authoritative movement attributes, locomotion state, and Speed/Slowness effects while retaining conservative geometry and lifecycle exclusions. It does not cancel movement, change flight permissions, punish players, persist player evidence, or export telemetry.
|
||||
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement, contextually impossible ground speed, sustained unauthorized unsupported flight, and occupied boats or rafts that sustain unsupported ascent, hovering, or level travel. Contextual ground-speed evidence uses server-authoritative movement attributes, locomotion state, and Speed/Slowness effects while vehicle and flight evidence uses bounded loaded-geometry checks and conservative lifecycle exclusions. It does not cancel movement, alter vehicles, change flight permissions, punish players, persist player evidence, or export telemetry.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -12,34 +12,40 @@ public record AlertDecision(
|
||||
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,
|
||||
peakSpeed / capturedLimit, 0, 0, 0, 0);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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, 0, 0, evidence.eligibleTicks(),
|
||||
evidence.horizontalDistance(), evidence.verticalDisplacement());
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence {
|
||||
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) {}
|
||||
}
|
||||
@@ -6,5 +6,8 @@ public enum DetectionSignal {
|
||||
UNAUTHORIZED_FLIGHT_STATE,
|
||||
UNSUPPORTED_ASCENT,
|
||||
UNSUPPORTED_HOVER,
|
||||
UNSUPPORTED_CRUISE
|
||||
UNSUPPORTED_CRUISE,
|
||||
UNSUPPORTED_BOAT_ASCENT,
|
||||
UNSUPPORTED_BOAT_HOVER,
|
||||
UNSUPPORTED_BOAT_CRUISE
|
||||
}
|
||||
@@ -106,7 +106,8 @@ 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, CONTEXTUAL_GROUND_SPEED -> false;
|
||||
case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED, UNSUPPORTED_BOAT_ASCENT,
|
||||
UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE -> false;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,25 @@ public record InspectionSnapshot(
|
||||
ExclusionReason currentExclusion,
|
||||
double cooldownRemainingSeconds,
|
||||
FlightEvidence flightEvidence,
|
||||
GroundSpeedEvidence contextualEvidence) {
|
||||
GroundSpeedEvidence contextualEvidence,
|
||||
BoatEvidence boatEvidence) {
|
||||
|
||||
public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit,
|
||||
ExclusionReason currentExclusion, double cooldownRemainingSeconds) {
|
||||
this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit,
|
||||
currentExclusion, cooldownRemainingSeconds, null, null);
|
||||
currentExclusion, cooldownRemainingSeconds, 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);
|
||||
currentExclusion, cooldownRemainingSeconds, flightEvidence, 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);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +11,7 @@ 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 Map<UUID, TickBucket> pending = new HashMap<>();
|
||||
private final List<AlertDecision> flightAlerts = new ArrayList<>();
|
||||
|
||||
@@ -22,6 +23,10 @@ public final class MovementMonitor {
|
||||
flightDetector.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 +54,7 @@ public final class MovementMonitor {
|
||||
}
|
||||
detector.cleanup(currentTick, nowNanos);
|
||||
flightDetector.cleanup(currentTick, nowNanos);
|
||||
boatDetector.cleanup(currentTick, nowNanos);
|
||||
return List.copyOf(alerts);
|
||||
}
|
||||
|
||||
@@ -61,30 +67,38 @@ 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);
|
||||
if (flight.isEmpty() && boat.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)));
|
||||
}
|
||||
var evidence = movement.get();
|
||||
return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(),
|
||||
evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(),
|
||||
evidence.cooldownRemainingSeconds(), flight.get(), evidence.contextualEvidence()));
|
||||
evidence.cooldownRemainingSeconds(), flight.orElse(null), evidence.contextualEvidence(),
|
||||
boat.orElse(null)));
|
||||
}
|
||||
|
||||
public void forget(UUID playerId) {
|
||||
pending.remove(playerId);
|
||||
detector.forget(playerId);
|
||||
flightDetector.forget(playerId);
|
||||
boatDetector.forget(playerId);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
pending.clear();
|
||||
detector.clear();
|
||||
flightDetector.clear();
|
||||
boatDetector.clear();
|
||||
flightAlerts.clear();
|
||||
}
|
||||
|
||||
public void releaseBoat(UUID boatId) {
|
||||
boatDetector.release(boatId);
|
||||
}
|
||||
|
||||
int trackedPlayerCount() {
|
||||
return detector.trackedPlayerCount();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.bukkit.scheduler.BukkitTask;
|
||||
public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
private MovementMonitor monitor;
|
||||
private BukkitMovementListener listener;
|
||||
private BukkitBoatObserver boatObserver;
|
||||
private BukkitTask tickTask;
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -20,14 +21,17 @@ 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);
|
||||
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);
|
||||
var alerts = new SentinelAlerts(getServer());
|
||||
tickTask = getServer().getScheduler().runTaskTimer(this, () -> {
|
||||
listener.sampleFlight();
|
||||
boatObserver.sample();
|
||||
monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send);
|
||||
}, 1L, 1L);
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
@@ -46,6 +50,11 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
HandlerList.unregisterAll(listener);
|
||||
listener = null;
|
||||
}
|
||||
if (boatObserver != null) {
|
||||
boatObserver.clear();
|
||||
HandlerList.unregisterAll(boatObserver);
|
||||
boatObserver = null;
|
||||
}
|
||||
if (monitor != null) {
|
||||
monitor.clear();
|
||||
monitor = null;
|
||||
|
||||
@@ -24,6 +24,11 @@ final class SentinelAlerts {
|
||||
"[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.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,16 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
var evidence = snapshot.get();
|
||||
if (evidence.flightEvidence() != null) {
|
||||
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.",
|
||||
|
||||
@@ -12,12 +12,27 @@ public record SentinelSettings(
|
||||
double unsupportedAscentBlocks,
|
||||
double unsupportedCruiseBlocks,
|
||||
double contextualSpeedRatio,
|
||||
double contextualMinimumExcess) {
|
||||
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, 1.35, 0.10);
|
||||
minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0, 1.35, 0.10,
|
||||
40, 30, 2.0, 12.0);
|
||||
}
|
||||
|
||||
public SentinelSettings {
|
||||
@@ -33,10 +48,15 @@ public record SentinelSettings(
|
||||
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, 1.35, 0.10);
|
||||
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) {
|
||||
|
||||
@@ -16,7 +16,11 @@ public enum SettingKey {
|
||||
UNSUPPORTED_ASCENT_BLOCKS("unsupported-ascent-blocks", "0.5", "20.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);
|
||||
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;
|
||||
|
||||
@@ -49,7 +49,11 @@ public final class SettingsService {
|
||||
optionalDecimal(values, SettingKey.UNSUPPORTED_ASCENT_BLOCKS, 2.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));
|
||||
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) {
|
||||
@@ -112,6 +116,10 @@ public final class SettingsService {
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,7 @@ 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
|
||||
@@ -45,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);
|
||||
@@ -111,7 +112,10 @@ 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));
|
||||
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)));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ class DistributionTest {
|
||||
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))) {
|
||||
|
||||
@@ -51,6 +51,27 @@ class SentinelAlertsTest {
|
||||
&& !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);
|
||||
|
||||
@@ -71,6 +71,24 @@ class SentinelCommandTest {
|
||||
verify(online, never()).teleport(any(org.bukkit.Location.class));
|
||||
}
|
||||
|
||||
@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");
|
||||
|
||||
@@ -112,6 +112,28 @@ class SettingsServiceTest {
|
||||
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