feat(sentinel): detect unsupported boat travel #4

Merged
dmg merged 1 commits from ticket-347-alert-operators-to-sustained-unsupported-boat-travel into main 2026-10-29 21:00:29 +00:00
27 changed files with 832 additions and 22 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Passive, explainable movement-pattern observation for Purpur servers.
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement, 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);
};
}
+4
View File
@@ -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);