Compare commits

...
4 Commits
Author SHA1 Message Date
dmg a31966d148 feat(sentinel): detect unsupported boat travel
Release / release (push) Successful in 2m0s
CI / build (push) Successful in 1m14s
Merge PR #4 for Kaneo SM-347 after exact-candidate checks 3818 and 3819 passed.
2026-10-29 21:00:28 +00:00
dmg 1c226d14ef feat(sentinel): detect unsupported boat travel
CI / build (pull_request) Successful in 1m15s
CI / build (push) Successful in 3m28s
2026-10-29 16:56:29 -04:00
dmg 062e390386 feat(sentinel): detect contextual ground speed
CI / build (push) Successful in 1m7s
Release / release (push) Failing after 11m29s
Merge PR #3 for Kaneo SM-346 after exact-candidate checks 3812 and 3813 passed.
2026-10-29 20:47:00 +00:00
dmg 0db3f32bd3 feat(sentinel): detect contextual ground speed
CI / build (push) Successful in 1m12s
CI / build (pull_request) Successful in 1m19s
2026-10-29 16:44:57 -04:00
36 changed files with 1425 additions and 88 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 and sustained unauthorized unsupported flight. It does not cancel movement, change flight permissions, punish players, persist player evidence, or export telemetry.
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement, contextually impossible ground speed, 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
@@ -8,16 +8,44 @@ public record AlertDecision(
int breachCount,
double peakSpeed,
double capturedLimit,
double ratio,
int evidenceWindowSeconds,
int eligibleTicks,
double horizontalDistance,
double verticalDisplacement) {
double verticalDisplacement,
String vehicleType) {
public AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) {
this(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, peakSpeed, capturedLimit, 0, 0, 0);
this(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, peakSpeed, capturedLimit,
peakSpeed / capturedLimit, 0, 0, 0, 0, null);
}
public AlertDecision(UUID playerId, DetectionSignal signal, int breachCount, double peakSpeed,
double capturedLimit, int eligibleTicks, double horizontalDistance, double verticalDisplacement) {
this(playerId, signal, breachCount, peakSpeed, capturedLimit,
capturedLimit > 0 ? peakSpeed / capturedLimit : 0, 0,
eligibleTicks, horizontalDistance, verticalDisplacement, null);
}
public static AlertDecision absolute(UUID playerId, int breachCount, double observedSpeed,
double capturedLimit, int evidenceWindowSeconds) {
return new AlertDecision(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount,
observedSpeed, capturedLimit, observedSpeed / capturedLimit, evidenceWindowSeconds, 0, 0, 0, null);
}
public static AlertDecision contextual(UUID playerId, int breachCount, double observedSpeed,
double expectedLimit, int evidenceWindowSeconds) {
return new AlertDecision(playerId, DetectionSignal.CONTEXTUAL_GROUND_SPEED, breachCount,
observedSpeed, expectedLimit, observedSpeed / expectedLimit, evidenceWindowSeconds, 0, 0, 0, null);
}
public static AlertDecision flight(UUID playerId, FlightEvidence evidence) {
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, evidence.eligibleTicks(),
evidence.horizontalDistance(), evidence.verticalDisplacement());
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(),
evidence.horizontalDistance(), evidence.verticalDisplacement(), null);
}
public static AlertDecision boat(UUID playerId, BoatEvidence evidence) {
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(),
evidence.horizontalDistance(), evidence.verticalDisplacement(), evidence.vehicleType());
}
}
@@ -33,6 +33,12 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence {
unsupported-hover-ticks: 30
unsupported-ascent-blocks: 2.0
unsupported-cruise-blocks: 10.0
contextual-speed-ratio: 1.35
contextual-minimum-excess: 0.10
unsupported-boat-ticks: 40
unsupported-boat-hover-ticks: 30
unsupported-boat-ascent-blocks: 2.0
unsupported-boat-cruise-blocks: 12.0
""", StandardOpenOption.CREATE_NEW);
}
}
@@ -0,0 +1,194 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
final class BoatDetector {
private final Map<UUID, State> boats = new HashMap<>();
private final Map<UUID, UUID> controllerBoats = new HashMap<>();
Optional<AlertDecision> observe(BoatFrame frame, SentinelSettings settings) {
releaseOtherBoat(frame.controllerId(), frame.vehicleId());
var state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State());
if (state.controller != null && !state.controller.equals(frame.controllerId())) release(frame.vehicleId());
state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State());
controllerBoats.put(frame.controllerId(), frame.vehicleId());
state.controller = frame.controllerId();
state.vehicleId = frame.vehicleId();
state.vehicleType = frame.vehicleType();
state.expire(frame.observedAtNanos());
if (ineligible(frame)) {
state.resetSequence();
state.supportState = frame.supportState();
state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE
? ExclusionReason.ENVIRONMENT : frame.exclusionReason();
state.graceThroughTick = Math.max(state.graceThroughTick,
frame.tick() + settings.exemptionGraceTicks());
return Optional.empty();
}
if (frame.tick() <= state.graceThroughTick) return Optional.empty();
if (!state.connected(frame)) state.startSequence(frame);
state.lastTick = frame.tick();
state.supportState = SupportState.UNSUPPORTED;
state.currentExclusion = ExclusionReason.NONE;
double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
double vertical = frame.toY() - frame.fromY();
state.eligibleTicks++;
state.horizontalDistance += horizontal;
state.verticalDisplacement = frame.toY() - state.startY;
if (vertical > 0.01) state.ascentTicks++;
if (Math.abs(vertical) <= 0.01 && horizontal <= 0.03) state.hoverTicks++;
if (Math.abs(vertical) <= 0.01 && horizontal > 0.03) state.cruiseTicks++;
state.signal = signal(state, settings);
if (!thresholdReached(state, settings)) return Optional.empty();
state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds());
state.lastEvidence = state.evidence(frame.observedAtNanos());
state.evidenceFirstObservedAtNanos = state.firstObservedAtNanos;
if (frame.observedAtNanos() < state.cooldownUntilNanos) return Optional.empty();
state.cooldownUntilNanos = frame.observedAtNanos() + seconds(settings.alertCooldownSeconds());
state.lastEvidence = state.evidence(frame.observedAtNanos());
return Optional.of(AlertDecision.boat(frame.controllerId(), state.lastEvidence));
}
Optional<BoatEvidence> inspect(UUID playerId, long nowNanos) {
UUID boatId = controllerBoats.get(playerId);
if (boatId == null) return Optional.empty();
var state = boats.get(boatId);
if (state == null || !playerId.equals(state.controller)) return Optional.empty();
state.expire(nowNanos);
return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos));
}
void release(UUID boatId) {
var state = boats.remove(boatId);
if (state != null && state.controller != null) controllerBoats.remove(state.controller, boatId);
}
void forget(UUID playerId) {
var boatId = controllerBoats.remove(playerId);
if (boatId != null) boats.remove(boatId);
}
void cleanup(int currentTick, long nowNanos) {
var iterator = boats.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
var state = entry.getValue();
state.expire(nowNanos);
if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence();
if (state.world == null && state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos
&& currentTick > state.graceThroughTick) {
controllerBoats.remove(state.controller, entry.getKey());
iterator.remove();
}
}
}
void clear() { boats.clear(); controllerBoats.clear(); }
private void releaseOtherBoat(UUID controllerId, UUID currentBoat) {
var previous = controllerBoats.get(controllerId);
if (previous != null && !previous.equals(currentBoat)) release(previous);
}
private static boolean ineligible(BoatFrame frame) {
return frame.supportState() != SupportState.UNSUPPORTED
|| frame.exclusionReason() != ExclusionReason.NONE
|| !finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ());
}
private static DetectionSignal signal(State state, SentinelSettings settings) {
if (state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks()) {
return DetectionSignal.UNSUPPORTED_BOAT_ASCENT;
}
if (state.hoverTicks >= settings.unsupportedBoatHoverTicks()) {
return DetectionSignal.UNSUPPORTED_BOAT_HOVER;
}
return DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
}
private static boolean thresholdReached(State state, SentinelSettings settings) {
return switch (state.signal) {
case UNSUPPORTED_BOAT_ASCENT -> state.eligibleTicks >= settings.unsupportedBoatTicks()
&& state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks();
case UNSUPPORTED_BOAT_HOVER -> state.hoverTicks >= settings.unsupportedBoatHoverTicks();
case UNSUPPORTED_BOAT_CRUISE -> state.cruiseTicks >= settings.unsupportedBoatTicks()
&& state.horizontalDistance >= settings.unsupportedBoatCruiseBlocks();
default -> false;
};
}
private static long seconds(int value) { return value * 1_000_000_000L; }
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
}
private static final class State {
private UUID vehicleId;
private UUID controller;
private UUID world;
private String vehicleType = "UNKNOWN_BOAT";
private int lastTick = Integer.MIN_VALUE;
private int graceThroughTick = Integer.MIN_VALUE;
private long firstObservedAtNanos;
private long evidenceFirstObservedAtNanos;
private long evidenceExpiresAtNanos;
private long cooldownUntilNanos;
private double startY;
private int eligibleTicks;
private int ascentTicks;
private int hoverTicks;
private int cruiseTicks;
private double horizontalDistance;
private double verticalDisplacement;
private SupportState supportState = SupportState.UNKNOWN;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
private DetectionSignal signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
private BoatEvidence lastEvidence;
private boolean connected(BoatFrame frame) {
return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick();
}
private void startSequence(BoatFrame frame) {
resetSequence();
world = frame.world();
lastTick = frame.tick() - 1;
firstObservedAtNanos = frame.observedAtNanos();
startY = frame.fromY();
}
private void resetSequence() {
world = null;
lastTick = Integer.MIN_VALUE;
firstObservedAtNanos = 0;
startY = 0;
eligibleTicks = ascentTicks = hoverTicks = cruiseTicks = 0;
horizontalDistance = verticalDisplacement = 0;
signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
}
private void expire(long nowNanos) {
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
}
private BoatEvidence evidence(long nowNanos) {
var source = lastEvidence;
int ticks = eligibleTicks, climbs = ascentTicks, hovers = hoverTicks, cruises = cruiseTicks;
double horizontal = horizontalDistance, vertical = verticalDisplacement;
DetectionSignal currentSignal = signal;
long first = firstObservedAtNanos;
if (eligibleTicks == 0 && source != null) {
ticks = source.eligibleTicks(); climbs = source.ascentTicks(); hovers = source.hoverTicks();
cruises = source.cruiseTicks(); horizontal = source.horizontalDistance();
vertical = source.verticalDisplacement(); currentSignal = source.signal();
first = evidenceFirstObservedAtNanos;
}
return new BoatEvidence(currentSignal, vehicleId, vehicleType, supportState, ticks, climbs, hovers,
cruises, horizontal, vertical, Math.max(0, nowNanos - first) / 1_000_000_000.0,
Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0, currentExclusion);
}
}
}
@@ -0,0 +1,19 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record BoatEvidence(
DetectionSignal signal,
UUID vehicleId,
String vehicleType,
SupportState supportState,
int eligibleTicks,
int ascentTicks,
int hoverTicks,
int cruiseTicks,
double horizontalDistance,
double verticalDisplacement,
double evidenceAgeSeconds,
double cooldownRemainingSeconds,
ExclusionReason currentExclusion) {
}
@@ -0,0 +1,27 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record BoatFrame(
int tick,
long observedAtNanos,
UUID vehicleId,
UUID controllerId,
UUID world,
String vehicleType,
double fromX,
double fromY,
double fromZ,
double toX,
double toY,
double toZ,
SupportState supportState,
ExclusionReason exclusionReason) {
public static BoatFrame unsupported(int tick, long observedAtNanos, UUID vehicleId, UUID controllerId,
UUID world, String vehicleType, double fromX, double fromY, double fromZ,
double toX, double toY, double toZ) {
return new BoatFrame(tick, observedAtNanos, vehicleId, controllerId, world, vehicleType,
fromX, fromY, fromZ, toX, toY, toZ, SupportState.UNSUPPORTED, ExclusionReason.NONE);
}
}
@@ -0,0 +1,138 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Boat;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.vehicle.VehicleBlockCollisionEvent;
import org.bukkit.event.vehicle.VehicleDestroyEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.event.vehicle.VehicleExitEvent;
final class BukkitBoatObserver implements Listener {
private final Server server;
private final MovementMonitor monitor;
private final Supplier<SentinelSettings> settings;
private final LongSupplier nanoTime;
private final Function<Location, SupportState> supportState;
private final Map<UUID, Sample> lastSamples = new HashMap<>();
BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime, MovementEnvironment::boatSupportState);
}
BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, Function<Location, SupportState> supportState) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
this.supportState = supportState;
}
void sample() {
int tick = server.getCurrentTick();
long observedAtNanos = nanoTime.getAsLong();
var seen = new HashSet<UUID>();
for (var world : server.getWorlds()) {
for (Boat boat : world.getEntitiesByClass(Boat.class)) {
if (!isBoatFamily(boat.getType()) || !boat.isValid() || boat.isDead()) {
release(boat.getUniqueId());
continue;
}
seen.add(boat.getUniqueId());
var controller = boat.getPassengers().stream().filter(Player.class::isInstance)
.map(Player.class::cast).findFirst().orElse(null);
if (controller == null) {
release(boat.getUniqueId());
continue;
}
Location current = boat.getLocation();
if (current == null || current.getWorld() == null) {
release(boat.getUniqueId());
continue;
}
Sample prior = lastSamples.get(boat.getUniqueId());
ExclusionReason reason = classify(boat, controller, current, prior);
Location previous = prior == null || !prior.world.equals(current.getWorld().getUID())
? current : prior.location;
monitor.record(new BoatFrame(tick, observedAtNanos, boat.getUniqueId(), controller.getUniqueId(),
current.getWorld().getUID(), boat.getType().name(), previous.getX(), previous.getY(), previous.getZ(),
current.getX(), current.getY(), current.getZ(), supportState.apply(current), reason), settings.get());
lastSamples.put(boat.getUniqueId(), new Sample(current.clone(), current.getWorld().getUID(),
controller.getUniqueId(), current.getBlockX() >> 4, current.getBlockZ() >> 4));
}
}
for (UUID boatId : new HashSet<>(lastSamples.keySet())) {
if (!seen.contains(boatId)) release(boatId);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEnter(VehicleEnterEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onExit(VehicleExitEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDestroy(VehicleDestroyEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR)
public void onCollision(VehicleBlockCollisionEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(EntityTeleportEvent event) {
if (event.getEntity() instanceof Boat boat) release(boat.getUniqueId());
}
void clear() {
lastSamples.clear();
}
int trackedBoatCount() { return lastSamples.size(); }
static boolean isBoatFamily(EntityType type) {
String name = type.name();
return name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT")
|| name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT");
}
private ExclusionReason classify(Boat boat, Player controller, Location current, Sample prior) {
if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS;
if (prior == null || !prior.world.equals(current.getWorld().getUID())
|| !prior.controller.equals(controller.getUniqueId())
|| prior.chunkX != current.getBlockX() >> 4 || prior.chunkZ != current.getBlockZ() >> 4) {
return ExclusionReason.LIFECYCLE;
}
double displacement = Math.sqrt(prior.location.distanceSquared(current));
if (!Double.isFinite(displacement) || displacement > 8.0) return ExclusionReason.LIFECYCLE;
var velocity = boat.getVelocity();
if (velocity != null && (Math.abs(velocity.getX()) > 1.5 || Math.abs(velocity.getY()) > 1.0
|| Math.abs(velocity.getZ()) > 1.5)) return ExclusionReason.VELOCITY;
return ExclusionReason.NONE;
}
private void releaseIfBoat(org.bukkit.entity.Vehicle vehicle) {
if (vehicle instanceof Boat boat) release(boat.getUniqueId());
}
private void release(UUID boatId) {
lastSamples.remove(boatId);
monitor.releaseBoat(boatId);
}
private record Sample(Location location, UUID world, UUID controller, int chunkX, int chunkZ) {}
}
@@ -10,6 +10,7 @@ import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Pose;
import org.bukkit.Server;
import org.bukkit.entity.Player;
@@ -33,6 +34,7 @@ public final class BukkitMovementListener implements Listener {
private final LongSupplier nanoTime;
private final BiPredicate<Location, Location> ordinaryEnvironment;
private final Function<Location, SupportState> supportState;
private final Function<Player, GroundMovementContext> groundMovementContext;
private final Map<UUID, Location> lastFlightSamples = new HashMap<>();
public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) {
@@ -43,18 +45,28 @@ public final class BukkitMovementListener implements Listener {
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState);
this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState,
BukkitMovementListener::groundContext);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState,
BukkitMovementListener::groundContext);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState,
Function<Player, GroundMovementContext> groundMovementContext) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
this.ordinaryEnvironment = ordinaryEnvironment;
this.supportState = supportState;
this.groundMovementContext = groundMovementContext;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
@@ -69,7 +81,8 @@ public final class BukkitMovementListener implements Listener {
nanoTime.getAsLong(),
Objects.requireNonNull(from.getWorld()).getUID(),
Objects.requireNonNull(to.getWorld()).getUID(),
from.getX(), from.getZ(), to.getX(), to.getZ(), reason), settings.get());
from.getX(), from.getZ(), to.getX(), to.getZ(), reason,
reason == ExclusionReason.NONE ? groundMovementContext.apply(player) : null), settings.get());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
@@ -145,14 +158,32 @@ public final class BukkitMovementListener implements Listener {
if (player.isClimbing()) return ExclusionReason.CLIMBING;
if (player.isRiptiding() || player.getPose() == Pose.SPIN_ATTACK) return ExclusionReason.RIPTIDE;
if (player.getActivePotionEffects().stream().map(effect -> effect.getType().getKey().getKey())
.anyMatch(key -> key.equals("speed") || key.equals("slowness")
|| key.equals("jump_boost") || key.equals("levitation") || key.equals("slow_falling"))) {
.anyMatch(key -> key.equals("jump_boost") || key.equals("levitation") || key.equals("slow_falling"))) {
return ExclusionReason.POTION_EFFECT;
}
if (!ordinaryEnvironment.test(from, to)) return ExclusionReason.ENVIRONMENT;
return ExclusionReason.NONE;
}
static GroundMovementContext groundContext(Player player) {
var movement = player.getAttribute(Attribute.MOVEMENT_SPEED);
if (movement == null || !Double.isFinite(movement.getValue()) || movement.getValue() <= 0) return null;
var sneaking = player.getAttribute(Attribute.SNEAKING_SPEED);
double sneakingMultiplier = sneaking == null ? 0.3 : sneaking.getValue();
if (!Double.isFinite(sneakingMultiplier) || sneakingMultiplier <= 0) return null;
int speed = effectAmplifier(player, "speed");
int slowness = effectAmplifier(player, "slowness");
return new GroundMovementContext(movement.getValue(), sneakingMultiplier, player.isSprinting(),
player.isSneaking(), player.isOnGround(), speed, slowness);
}
private static int effectAmplifier(Player player, String key) {
return player.getActivePotionEffects().stream()
.filter(effect -> effect.getType().getKey().getKey().equals(key))
.mapToInt(org.bukkit.potion.PotionEffect::getAmplifier)
.max().orElse(-1);
}
private static boolean authorizedFlight(Player player) {
return player.getGameMode() == GameMode.CREATIVE
|| player.getGameMode() == GameMode.SPECTATOR
@@ -2,8 +2,12 @@ package games.dmg.purpursentinel;
public enum DetectionSignal {
ABSOLUTE_GROUND_SPEED,
CONTEXTUAL_GROUND_SPEED,
UNAUTHORIZED_FLIGHT_STATE,
UNSUPPORTED_ASCENT,
UNSUPPORTED_HOVER,
UNSUPPORTED_CRUISE
UNSUPPORTED_CRUISE,
UNSUPPORTED_BOAT_ASCENT,
UNSUPPORTED_BOAT_HOVER,
UNSUPPORTED_BOAT_CRUISE
}
@@ -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 -> false;
case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED, UNSUPPORTED_BOAT_ASCENT,
UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE -> false;
};
}
@@ -0,0 +1,58 @@
package games.dmg.purpursentinel;
public record GroundMovementContext(
double movementSpeedAttribute,
double sneakingMultiplier,
boolean sprinting,
boolean sneaking,
boolean grounded,
int speedAmplifier,
int slownessAmplifier) {
private static final double ATTRIBUTE_TO_BLOCKS_PER_TICK = 2.2;
private static final double SPRINT_MULTIPLIER = 1.3;
private static final double AIRBORNE_MOMENTUM_MULTIPLIER = 1.3;
public GroundMovementContext {
if (!Double.isFinite(movementSpeedAttribute) || movementSpeedAttribute <= 0) {
throw new IllegalArgumentException("movement speed attribute must be positive and finite");
}
if (!Double.isFinite(sneakingMultiplier) || sneakingMultiplier <= 0) {
throw new IllegalArgumentException("sneaking multiplier must be positive and finite");
}
if (speedAmplifier < -1 || slownessAmplifier < -1) {
throw new IllegalArgumentException("effect amplifiers must be -1 or greater");
}
}
public static GroundMovementContext walking(double movementSpeedAttribute) {
return withEffects(movementSpeedAttribute, false, false, true, -1, -1);
}
public static GroundMovementContext sprinting(double baseMovementSpeedAttribute, boolean grounded) {
return withEffects(baseMovementSpeedAttribute, true, false, grounded, -1, -1);
}
public static GroundMovementContext sneaking(double movementSpeedAttribute) {
return new GroundMovementContext(movementSpeedAttribute, 0.3, false, true, true, -1, -1);
}
public static GroundMovementContext withEffects(double baseMovementSpeedAttribute, boolean sprinting,
boolean sneaking, boolean grounded, int speedAmplifier, int slownessAmplifier) {
double effectiveAttribute = baseMovementSpeedAttribute;
if (sprinting) effectiveAttribute *= SPRINT_MULTIPLIER;
if (speedAmplifier >= 0) effectiveAttribute *= 1.0 + 0.20 * (speedAmplifier + 1);
if (slownessAmplifier >= 0) {
effectiveAttribute *= Math.max(0.10, 1.0 - 0.15 * (slownessAmplifier + 1));
}
return new GroundMovementContext(effectiveAttribute, 0.3, sprinting, sneaking, grounded,
speedAmplifier, slownessAmplifier);
}
public double expectedSpeed() {
// Bukkit's effective MOVEMENT_SPEED value already includes sprint and potion modifiers.
double expected = movementSpeedAttribute * ATTRIBUTE_TO_BLOCKS_PER_TICK;
if (sneaking) expected *= sneakingMultiplier;
if (!grounded) expected *= AIRBORNE_MOMENTUM_MULTIPLIER;
return expected;
}
}
@@ -0,0 +1,12 @@
package games.dmg.purpursentinel;
public record GroundSpeedEvidence(
DetectionSignal signal,
int breachCount,
double evidenceAgeSeconds,
double observedSpeed,
double expectedLimit,
double ratio,
ExclusionReason currentExclusion,
double cooldownRemainingSeconds) {
}
@@ -7,11 +7,26 @@ public record InspectionSnapshot(
double capturedLimit,
ExclusionReason currentExclusion,
double cooldownRemainingSeconds,
FlightEvidence flightEvidence) {
FlightEvidence flightEvidence,
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);
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, 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);
}
}
@@ -21,27 +21,36 @@ public final class MovementDetector {
state.graceThroughTick = Math.max(state.graceThroughTick, sample.tick() + settings.exemptionGraceTicks());
return Optional.empty();
}
if (sample.tick() <= state.graceThroughTick) {
return Optional.empty();
}
if (sample.tick() <= state.graceThroughTick) return Optional.empty();
state.currentExclusion = ExclusionReason.NONE;
if (sample.horizontalDistance() <= settings.horizontalSpeedLimit()) {
return Optional.empty();
}
if (state.evidence.size() == MAX_EVIDENCE_PER_PLAYER) {
state.evidence.removeFirst();
}
state.evidence.addLast(new Breach(
sample.observedAtNanos(),
sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()),
sample.horizontalDistance(),
settings.horizontalSpeedLimit()));
if (state.evidence.size() < settings.breachesToAlert() || sample.observedAtNanos() < state.cooldownUntilNanos) {
boolean absolute = sample.horizontalDistance() > settings.horizontalSpeedLimit();
double expectedLimit = sample.groundContext() == null ? Double.NaN : sample.groundContext().expectedSpeed();
boolean contextual = Double.isFinite(expectedLimit)
&& sample.horizontalDistance() > expectedLimit * settings.contextualSpeedRatio()
&& sample.horizontalDistance() - expectedLimit > settings.contextualMinimumExcess();
if (!absolute && !contextual) return Optional.empty();
var breach = new Breach(sample.observedAtNanos(),
sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()), sample.horizontalDistance(), 0);
if (absolute) add(state.absoluteEvidence,
breach.withCapturedLimit(settings.horizontalSpeedLimit()));
if (contextual) add(state.contextualEvidence, breach.withCapturedLimit(expectedLimit));
boolean absoluteReady = absolute && state.absoluteEvidence.size() >= settings.breachesToAlert();
boolean contextualReady = contextual && state.contextualEvidence.size() >= settings.breachesToAlert();
if ((!absoluteReady && !contextualReady) || sample.observedAtNanos() < state.cooldownUntilNanos) {
return Optional.empty();
}
state.cooldownUntilNanos = sample.observedAtNanos() + seconds(settings.alertCooldownSeconds());
var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new AlertDecision(playerId, state.evidence.size(), peak.speed(), peak.capturedLimit()));
if (absoluteReady) {
var peak = state.absoluteEvidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(AlertDecision.absolute(playerId, state.absoluteEvidence.size(),
peak.speed(), peak.capturedLimit(), settings.evidenceWindowSeconds()));
}
var peak = state.contextualEvidence.stream().max(Comparator.comparingDouble(Breach::ratio)).orElseThrow();
return Optional.of(AlertDecision.contextual(playerId, state.contextualEvidence.size(), peak.speed(),
peak.capturedLimit(), settings.evidenceWindowSeconds()));
}
public void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason, SentinelSettings settings) {
@@ -53,65 +62,78 @@ public final class MovementDetector {
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
var state = players.get(playerId);
if (state == null) {
return Optional.empty();
}
if (state == null) return Optional.empty();
expire(state, nowNanos);
if (state.evidence.isEmpty()) {
return Optional.empty();
if (state.absoluteEvidence.isEmpty() && state.contextualEvidence.isEmpty()) return Optional.empty();
GroundSpeedEvidence contextual = snapshot(state.contextualEvidence, DetectionSignal.CONTEXTUAL_GROUND_SPEED,
state, nowNanos).orElse(null);
if (state.absoluteEvidence.isEmpty()) {
return Optional.of(new InspectionSnapshot(0, 0, 0, 0, state.currentExclusion,
cooldown(state, nowNanos), null, contextual));
}
var first = state.evidence.getFirst();
var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new InspectionSnapshot(
state.evidence.size(),
Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0,
peak.speed(),
peak.capturedLimit(),
state.currentExclusion,
Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0));
var first = state.absoluteEvidence.getFirst();
var peak = state.absoluteEvidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new InspectionSnapshot(state.absoluteEvidence.size(), age(first, nowNanos),
peak.speed(), peak.capturedLimit(), state.currentExclusion, cooldown(state, nowNanos),
null, contextual));
}
public void cleanup(int currentTick, long nowNanos) {
players.entrySet().removeIf(entry -> {
var state = entry.getValue();
expire(state, nowNanos);
return state.evidence.isEmpty() && state.cooldownUntilNanos <= nowNanos
&& currentTick > state.graceThroughTick;
return state.absoluteEvidence.isEmpty() && state.contextualEvidence.isEmpty()
&& state.cooldownUntilNanos <= nowNanos && currentTick > state.graceThroughTick;
});
}
public void forget(UUID playerId) {
players.remove(playerId);
}
public void clear() {
players.clear();
}
int trackedPlayerCount() {
return players.size();
}
public void forget(UUID playerId) { players.remove(playerId); }
public void clear() { players.clear(); }
int trackedPlayerCount() { return players.size(); }
int evidenceCount(UUID playerId) {
var state = players.get(playerId);
return state == null ? 0 : state.evidence.size();
return state == null ? 0 : state.absoluteEvidence.size() + state.contextualEvidence.size();
}
private static long seconds(int value) {
return value * 1_000_000_000L;
private static Optional<GroundSpeedEvidence> snapshot(ArrayDeque<Breach> evidence, DetectionSignal signal,
PlayerState state, long nowNanos) {
if (evidence.isEmpty()) return Optional.empty();
var peak = evidence.stream().max(Comparator.comparingDouble(Breach::ratio)).orElseThrow();
return Optional.of(new GroundSpeedEvidence(signal, evidence.size(), age(evidence.getFirst(), nowNanos),
peak.speed(), peak.capturedLimit(), peak.ratio(), state.currentExclusion,
cooldown(state, nowNanos)));
}
private static void add(ArrayDeque<Breach> evidence, Breach breach) {
if (evidence.size() == MAX_EVIDENCE_PER_PLAYER) evidence.removeFirst();
evidence.addLast(breach);
}
private static long seconds(int value) { return value * 1_000_000_000L; }
private static double age(Breach first, long nowNanos) {
return Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0;
}
private static double cooldown(PlayerState state, long nowNanos) {
return Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0;
}
private static void expire(PlayerState state, long nowNanos) {
while (!state.evidence.isEmpty() && state.evidence.getFirst().expiresAtNanos() <= nowNanos) {
state.evidence.removeFirst();
}
expire(state.absoluteEvidence, nowNanos);
expire(state.contextualEvidence, nowNanos);
}
private static void expire(ArrayDeque<Breach> evidence, long nowNanos) {
while (!evidence.isEmpty() && evidence.getFirst().expiresAtNanos() <= nowNanos) evidence.removeFirst();
}
private record Breach(long observedAtNanos, long expiresAtNanos, double speed, double capturedLimit) {
Breach withCapturedLimit(double value) {
return new Breach(observedAtNanos, expiresAtNanos, speed, value);
}
double ratio() { return speed / capturedLimit; }
}
private static final class PlayerState {
private final ArrayDeque<Breach> evidence = new ArrayDeque<>();
private final ArrayDeque<Breach> absoluteEvidence = new ArrayDeque<>();
private final ArrayDeque<Breach> contextualEvidence = new ArrayDeque<>();
private int graceThroughTick = Integer.MIN_VALUE;
private long cooldownUntilNanos;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
@@ -45,6 +45,27 @@ final class MovementEnvironment {
return SupportState.UNSUPPORTED;
}
static SupportState boatSupportState(Location location) {
var world = location.getWorld();
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
return SupportState.UNKNOWN;
}
int y = (int) Math.floor(location.getY() - 0.01);
boolean supported = false;
for (int x = location.getBlockX() - 1; x <= location.getBlockX() + 1; x++) {
for (int z = location.getBlockZ() - 1; z <= location.getBlockZ() + 1; z++) {
var block = world.getBlockAt(x, y, z);
var type = block.getType();
if (ALTERING.contains(type)) return SupportState.UNKNOWN;
if (block.isLiquid() || block.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged()
|| isIce(type) || type != Material.AIR && !block.isPassable()) {
supported = true;
}
}
}
return supported ? SupportState.SUPPORTED : SupportState.UNSUPPORTED;
}
static boolean isOrdinary(Location location) {
var world = location.getWorld();
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
@@ -11,8 +11,15 @@ public record MovementFrame(
double fromZ,
double toX,
double toZ,
ExclusionReason reason) {
ExclusionReason reason,
GroundMovementContext groundContext) {
public MovementFrame(int tick, long observedAtNanos, UUID fromWorld, UUID toWorld,
double fromX, double fromZ, double toX, double toZ, ExclusionReason reason) {
this(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ, reason, null);
}
public MovementFrame withReason(ExclusionReason replacement) {
return new MovementFrame(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ, replacement);
return new MovementFrame(tick, observedAtNanos, fromWorld, toWorld, fromX, fromZ, toX, toZ,
replacement, groundContext);
}
}
@@ -11,6 +11,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.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();
}
@@ -96,7 +110,8 @@ public final class MovementMonitor {
!bucket.invalid,
bucket.reason == ExclusionReason.CANCELLED,
bucket.maximumDistance,
bucket.reason), bucket.settings);
bucket.reason,
bucket.groundContext), bucket.settings);
}
private static final class TickBucket {
@@ -106,6 +121,7 @@ public final class MovementMonitor {
private final double anchorX;
private final double anchorZ;
private final SentinelSettings settings;
private final GroundMovementContext groundContext;
private double lastX;
private double lastZ;
private double previousFromX;
@@ -124,6 +140,7 @@ public final class MovementMonitor {
anchorX = frame.fromX();
anchorZ = frame.fromZ();
this.settings = settings;
groundContext = frame.groundContext();
lastX = frame.fromX();
lastZ = frame.fromZ();
reason = ExclusionReason.NONE;
@@ -137,7 +154,8 @@ public final class MovementMonitor {
void add(MovementFrame frame) {
if (!world.equals(frame.toWorld()) || !world.equals(frame.fromWorld())
|| !finite(frame.fromX(), frame.fromZ(), frame.toX(), frame.toZ())
|| !connected(frame.fromX(), frame.fromZ())) {
|| !connected(frame.fromX(), frame.fromZ())
|| !java.util.Objects.equals(groundContext, frame.groundContext())) {
invalid = true;
reason = ExclusionReason.INCOMPLETE;
return;
@@ -6,5 +6,10 @@ public record MovementSample(
boolean complete,
boolean cancelled,
double horizontalDistance,
ExclusionReason exclusionReason) {
ExclusionReason exclusionReason,
GroundMovementContext groundContext) {
public MovementSample(int tick, long observedAtNanos, boolean complete, boolean cancelled,
double horizontalDistance, ExclusionReason exclusionReason) {
this(tick, observedAtNanos, complete, cancelled, horizontalDistance, exclusionReason, null);
}
}
@@ -10,6 +10,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;
@@ -16,8 +16,19 @@ final class SentinelAlerts {
String message;
if (alert.signal() == DetectionSignal.ABSOLUTE_GROUND_SPEED) {
message = String.format(Locale.ROOT,
"[Sentinel] %s: %d movement breaches; peak %.2f blocks/tick vs captured limit %.2f. Use /sentinel inspect %s.",
subject.getName(), alert.breachCount(), alert.peakSpeed(), alert.capturedLimit(), subject.getName());
"[Sentinel] %s: ABSOLUTE_GROUND_SPEED; %d movement breaches in %ds window; peak %.2f blocks/tick vs captured limit %.2f; ratio %.2f. Use /sentinel inspect %s.",
subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(),
alert.capturedLimit(), alert.ratio(), subject.getName());
} else if (alert.signal() == DetectionSignal.CONTEXTUAL_GROUND_SPEED) {
message = String.format(Locale.ROOT,
"[Sentinel] %s: CONTEXTUAL_GROUND_SPEED; %d breaches in %ds window; observed %.2f blocks/tick vs expected %.2f; ratio %.2f. Use /sentinel inspect %s.",
subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(),
alert.capturedLimit(), alert.ratio(), subject.getName());
} else if (alert.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.",
@@ -102,11 +111,20 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
flight.cruiseTicks(), flight.horizontalDistance(), flight.verticalDisplacement(),
flight.evidenceAgeSeconds(), flight.currentExclusion().description(),
flight.cooldownRemainingSeconds()));
} else if (evidence.contextualEvidence() != null) {
var contextual = evidence.contextualEvidence();
sender.sendMessage(String.format(Locale.ROOT,
"%s: CONTEXTUAL_GROUND_SPEED; %d breaches; age %.1fs; observed %.2f blocks/tick; expected %.2f; ratio %.2f; exclusion %s; cooldown %.1fs; absolute breaches %d.",
player.get().getName(), contextual.breachCount(), contextual.evidenceAgeSeconds(),
contextual.observedSpeed(), contextual.expectedLimit(), contextual.ratio(),
contextual.currentExclusion().description(), contextual.cooldownRemainingSeconds(),
evidence.breachCount()));
} else {
sender.sendMessage(String.format(Locale.ROOT,
"%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.",
"%s: %d breaches; ABSOLUTE_GROUND_SPEED; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; ratio %.2f; exclusion %s; cooldown %.1fs.",
player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(),
evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
evidence.capturedLimit(), evidence.peakSpeed() / evidence.capturedLimit(),
evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
}
return true;
}
@@ -10,12 +10,29 @@ public record SentinelSettings(
int unsupportedFlightTicks,
int unsupportedHoverTicks,
double unsupportedAscentBlocks,
double unsupportedCruiseBlocks) {
double unsupportedCruiseBlocks,
double contextualSpeedRatio,
double contextualMinimumExcess,
int unsupportedBoatTicks,
int unsupportedBoatHoverTicks,
double unsupportedBoatAscentBlocks,
double unsupportedBoatCruiseBlocks) {
public SentinelSettings(double horizontalSpeedLimit, int breachesToAlert, int evidenceWindowSeconds,
int alertCooldownSeconds, double minimumTps, int exemptionGraceTicks,
int unsupportedFlightTicks, int unsupportedHoverTicks, double unsupportedAscentBlocks,
double unsupportedCruiseBlocks, double contextualSpeedRatio, double contextualMinimumExcess) {
this(horizontalSpeedLimit, breachesToAlert, evidenceWindowSeconds, alertCooldownSeconds,
minimumTps, exemptionGraceTicks, unsupportedFlightTicks, unsupportedHoverTicks,
unsupportedAscentBlocks, unsupportedCruiseBlocks, contextualSpeedRatio, contextualMinimumExcess,
40, 30, 2.0, 12.0);
}
public SentinelSettings(double horizontalSpeedLimit, int breachesToAlert, int evidenceWindowSeconds,
int alertCooldownSeconds, double minimumTps, int exemptionGraceTicks) {
this(horizontalSpeedLimit, breachesToAlert, evidenceWindowSeconds, alertCooldownSeconds,
minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0);
minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0, 1.35, 0.10,
40, 30, 2.0, 12.0);
}
public SentinelSettings {
@@ -29,10 +46,17 @@ public record SentinelSettings(
requireRange(unsupportedHoverTicks, 10, 200, "unsupported-hover-ticks");
requireFiniteRange(unsupportedAscentBlocks, 0.5, 20.0, "unsupported-ascent-blocks");
requireFiniteRange(unsupportedCruiseBlocks, 2.0, 100.0, "unsupported-cruise-blocks");
requireFiniteRange(contextualSpeedRatio, 1.05, 3.00, "contextual-speed-ratio");
requireFiniteRange(contextualMinimumExcess, 0.01, 1.00, "contextual-minimum-excess");
requireRange(unsupportedBoatTicks, 10, 200, "unsupported-boat-ticks");
requireRange(unsupportedBoatHoverTicks, 10, 200, "unsupported-boat-hover-ticks");
requireFiniteRange(unsupportedBoatAscentBlocks, 0.5, 20.0, "unsupported-boat-ascent-blocks");
requireFiniteRange(unsupportedBoatCruiseBlocks, 2.0, 100.0, "unsupported-boat-cruise-blocks");
}
public static SentinelSettings defaults() {
return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0);
return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0, 1.35, 0.10,
40, 30, 2.0, 12.0);
}
private static void requireRange(int value, int minimum, int maximum, String key) {
@@ -14,7 +14,13 @@ public enum SettingKey {
UNSUPPORTED_FLIGHT_TICKS("unsupported-flight-ticks", "10", "200", true),
UNSUPPORTED_HOVER_TICKS("unsupported-hover-ticks", "10", "200", true),
UNSUPPORTED_ASCENT_BLOCKS("unsupported-ascent-blocks", "0.5", "20.0", false),
UNSUPPORTED_CRUISE_BLOCKS("unsupported-cruise-blocks", "2.0", "100.0", false);
UNSUPPORTED_CRUISE_BLOCKS("unsupported-cruise-blocks", "2.0", "100.0", false),
CONTEXTUAL_SPEED_RATIO("contextual-speed-ratio", "1.05", "3.00", false),
CONTEXTUAL_MINIMUM_EXCESS("contextual-minimum-excess", "0.01", "1.00", false),
UNSUPPORTED_BOAT_TICKS("unsupported-boat-ticks", "10", "200", true),
UNSUPPORTED_BOAT_HOVER_TICKS("unsupported-boat-hover-ticks", "10", "200", true),
UNSUPPORTED_BOAT_ASCENT_BLOCKS("unsupported-boat-ascent-blocks", "0.5", "20.0", false),
UNSUPPORTED_BOAT_CRUISE_BLOCKS("unsupported-boat-cruise-blocks", "2.0", "100.0", false);
private final String key;
private final String minimum;
@@ -47,7 +47,13 @@ public final class SettingsService {
optionalInteger(values, SettingKey.UNSUPPORTED_FLIGHT_TICKS, 40),
optionalInteger(values, SettingKey.UNSUPPORTED_HOVER_TICKS, 30),
optionalDecimal(values, SettingKey.UNSUPPORTED_ASCENT_BLOCKS, 2.0),
optionalDecimal(values, SettingKey.UNSUPPORTED_CRUISE_BLOCKS, 10.0));
optionalDecimal(values, SettingKey.UNSUPPORTED_CRUISE_BLOCKS, 10.0),
optionalDecimal(values, SettingKey.CONTEXTUAL_SPEED_RATIO, 1.35),
optionalDecimal(values, SettingKey.CONTEXTUAL_MINIMUM_EXCESS, 0.10),
optionalInteger(values, SettingKey.UNSUPPORTED_BOAT_TICKS, 40),
optionalInteger(values, SettingKey.UNSUPPORTED_BOAT_HOVER_TICKS, 30),
optionalDecimal(values, SettingKey.UNSUPPORTED_BOAT_ASCENT_BLOCKS, 2.0),
optionalDecimal(values, SettingKey.UNSUPPORTED_BOAT_CRUISE_BLOCKS, 12.0));
}
private static Object parseValue(SettingKey key, String value) {
@@ -108,6 +114,12 @@ public final class SettingsService {
case UNSUPPORTED_HOVER_TICKS -> Integer.toString(settings.unsupportedHoverTicks());
case UNSUPPORTED_ASCENT_BLOCKS -> decimal(settings.unsupportedAscentBlocks(), 1);
case UNSUPPORTED_CRUISE_BLOCKS -> decimal(settings.unsupportedCruiseBlocks(), 1);
case CONTEXTUAL_SPEED_RATIO -> decimal(settings.contextualSpeedRatio(), 2);
case CONTEXTUAL_MINIMUM_EXCESS -> decimal(settings.contextualMinimumExcess(), 2);
case UNSUPPORTED_BOAT_TICKS -> Integer.toString(settings.unsupportedBoatTicks());
case UNSUPPORTED_BOAT_HOVER_TICKS -> Integer.toString(settings.unsupportedBoatHoverTicks());
case UNSUPPORTED_BOAT_ASCENT_BLOCKS -> decimal(settings.unsupportedBoatAscentBlocks(), 1);
case UNSUPPORTED_BOAT_CRUISE_BLOCKS -> decimal(settings.unsupportedBoatCruiseBlocks(), 1);
};
}
+6
View File
@@ -8,3 +8,9 @@ unsupported-flight-ticks: 40
unsupported-hover-ticks: 30
unsupported-ascent-blocks: 2.0
unsupported-cruise-blocks: 10.0
contextual-speed-ratio: 1.35
contextual-minimum-excess: 0.10
unsupported-boat-ticks: 40
unsupported-boat-hover-ticks: 30
unsupported-boat-ascent-blocks: 2.0
unsupported-boat-cruise-blocks: 12.0
@@ -15,6 +15,8 @@ import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.World;
import org.bukkit.command.PluginCommand;
import org.bukkit.entity.Player;
@@ -43,6 +45,7 @@ class PluginLifecycleTest {
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var currentTick = new java.util.concurrent.atomic.AtomicInteger(1);
when(server.getCurrentTick()).thenAnswer(call -> currentTick.get());
when(server.getWorlds()).thenReturn(List.of());
var scheduler = mock(BukkitScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
var manager = mock(PluginManager.class);
@@ -90,6 +93,10 @@ class PluginLifecycleTest {
when(subject.isOnline()).thenReturn(true);
when(subject.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(subject.getPose()).thenReturn(Pose.STANDING);
when(subject.isOnGround()).thenReturn(true);
var movement = mock(AttributeInstance.class);
when(movement.getValue()).thenReturn(0.1);
when(subject.getAttribute(Attribute.MOVEMENT_SPEED)).thenReturn(movement);
when(subject.getLocation()).thenReturn(new Location(world, 0, 64, 0));
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
when(operator.hasPermission("sentinel.inspect")).thenReturn(true);
@@ -105,7 +112,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)));
@@ -114,7 +124,8 @@ class PluginLifecycleTest {
}
verify(operator).sendMessage(argThat((String message) -> message.contains("6 movement breaches")));
assertTrue(command.execute(operator, "sentinel", new String[]{"inspect", "Alex"}));
verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: 6 breaches")));
verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: CONTEXTUAL_GROUND_SPEED")
&& message.contains("absolute breaches 6")));
assertEquals(List.of("inspect"), command.tabComplete(operator, "sentinel", new String[]{"in"}));
assertEquals(List.of("unsupported-flight-ticks"),
command.tabComplete(operator, "sentinel", new String[]{"config", "unsupported-f"}));
@@ -133,6 +144,30 @@ class PluginLifecycleTest {
}
}
@Test void nativeAttributesAndPotionAmplifiersFeedTheContextEnvelope() {
var player = mock(Player.class);
var movement = mock(AttributeInstance.class);
var sneaking = mock(AttributeInstance.class);
when(movement.getValue()).thenReturn(0.15);
when(sneaking.getValue()).thenReturn(0.4);
when(player.getAttribute(Attribute.MOVEMENT_SPEED)).thenReturn(movement);
when(player.getAttribute(Attribute.SNEAKING_SPEED)).thenReturn(sneaking);
when(player.isSprinting()).thenReturn(true);
when(player.isOnGround()).thenReturn(false);
when(player.getActivePotionEffects()).thenReturn(java.util.Set.of(
new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SPEED, 100, 2)));
var context = BukkitMovementListener.groundContext(player);
assertNotNull(context);
assertEquals(0.15, context.movementSpeedAttribute(), 1e-9);
assertEquals(0.4, context.sneakingMultiplier(), 1e-9);
assertTrue(context.sprinting());
assertFalse(context.grounded());
assertEquals(2, context.speedAmplifier());
assertEquals(-1, context.slownessAmplifier());
}
@Test void invalidStartupConfigurationRegistersNothing() throws Exception {
Files.writeString(directory.toPath().resolve("config.yml"), "minimum-tps: bad\n");
var server = mock(Server.class);
@@ -0,0 +1,102 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class BoatMonitorTest {
private static final UUID WORLD = UUID.randomUUID();
private static final UUID BOAT = UUID.randomUUID();
private static final UUID PLAYER = UUID.randomUUID();
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
@Test void sustainedHoverAlertsCurrentControllerAndExposesBoundedEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
for (int tick = 1; tick <= 30; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), SETTINGS);
}
var alert = monitor.finishBefore(31, nanos(31)).getFirst();
assertEquals(PLAYER, alert.playerId());
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_HOVER, alert.signal());
assertEquals("BAMBOO_RAFT", alert.vehicleType());
assertEquals(30, alert.eligibleTicks());
var evidence = monitor.inspect(PLAYER, nanos(31)).orElseThrow().boatEvidence();
assertEquals(BOAT, evidence.vehicleId());
assertEquals(SupportState.UNSUPPORTED, evidence.supportState());
assertEquals(30, evidence.hoverTicks());
}
@Test void ascentAndLevelTravelHaveIndependentPersistenceThresholds() {
var ascent = new MovementMonitor(new MovementDetector());
double y = 80;
for (int tick = 1; tick <= 40; tick++) {
ascent.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 0, y + 0.05, 0), SETTINGS);
y += 0.05;
}
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_ASCENT,
ascent.finishBefore(41, nanos(41)).getFirst().signal());
var cruise = new MovementMonitor(new MovementDetector());
double x = 0;
for (int tick = 1; tick <= 40; tick++) {
cruise.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"CHERRY_CHEST_BOAT", x, 80, 0, x + 0.3, 80, 0), SETTINGS);
x += 0.3;
}
var alert = cruise.finishBefore(41, nanos(41)).getFirst();
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, alert.signal());
assertEquals(12.0, alert.horizontalDistance(), 1e-9);
}
@Test void ascentStillRequiresPersistenceEvenWhenDisplacementThresholdIsReachedEarly() {
var monitor = new MovementMonitor(new MovementDetector());
double y = 80;
for (int tick = 1; tick <= 10; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 0, y + 0.25, 0), SETTINGS);
y += 0.25;
}
assertTrue(monitor.finishBefore(11, nanos(11)).isEmpty());
}
@Test void ordinaryFallSupportUnknownGeometryAndOneSpikeNeverAlert() {
var monitor = new MovementMonitor(new MovementDetector());
double y = 90;
for (int tick = 1; tick <= 50; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 0, y - 0.2, 0), SETTINGS);
y -= 0.2;
}
monitor.record(new BoatFrame(51, nanos(51), BOAT, PLAYER, WORLD, "OAK_BOAT",
0, y, 0, 0, y, 0, SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS);
monitor.record(BoatFrame.unsupported(72, nanos(72), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 2, y, 0), SETTINGS);
assertTrue(monitor.finishBefore(73, nanos(73)).isEmpty());
assertTrue(monitor.inspect(PLAYER, nanos(73)).isEmpty());
}
@Test void controllerChangesRemovalAndCleanupNeverAttributeFormerPassenger() {
var monitor = new MovementMonitor(new MovementDetector());
var replacement = UUID.randomUUID();
for (int tick = 1; tick <= 29; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS);
}
monitor.releaseBoat(BOAT);
for (int tick = 50; tick <= 79; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, replacement, WORLD,
"SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS);
}
var alert = monitor.finishBefore(80, nanos(80)).getFirst();
assertEquals(replacement, alert.playerId());
assertTrue(monitor.inspect(PLAYER, nanos(80)).isEmpty());
monitor.clear();
assertTrue(monitor.inspect(replacement, nanos(80)).isEmpty());
}
private static long nanos(int tick) { return tick * 50_000_000L; }
}
@@ -0,0 +1,48 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.stream.Stream;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class BoatSupportTest {
@ParameterizedTest(name = "{0}") @MethodSource("supportMaterials")
void waterIceAndCollidableTerrainSupportBoats(Material material) {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var block = mock(Block.class);
when(block.getType()).thenReturn(material);
when(block.isLiquid()).thenReturn(material == Material.WATER);
when(block.isPassable()).thenReturn(material == Material.WATER);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
assertEquals(SupportState.SUPPORTED, MovementEnvironment.boatSupportState(new Location(world, 0.5, 64, 0.5)));
}
@org.junit.jupiter.api.Test void pistonGeometryIsUnknownRatherThanEvidenceOrSupport() {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var block = mock(Block.class);
when(block.getType()).thenReturn(Material.MOVING_PISTON);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0)));
}
@org.junit.jupiter.api.Test void unloadedChunksRemainUnknownWithoutBlockReads() {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0)));
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
}
private static Stream<Material> supportMaterials() {
return Stream.of(Material.WATER, Material.ICE, Material.PACKED_ICE, Material.BLUE_ICE,
Material.OAK_PLANKS, Material.STONE);
}
}
@@ -0,0 +1,97 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Boat;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class BukkitBoatObserverTest {
@Test void everyPurpurBoatAndRaftTypeIsRecognized() {
var names = java.util.Arrays.stream(EntityType.values()).map(Enum::name)
.filter(name -> name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT")
|| name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT"))
.toList();
assertFalse(names.isEmpty());
names.forEach(name -> assertTrue(BukkitBoatObserver.isBoatFamily(EntityType.valueOf(name)), name));
}
@Test void samplesServerBoatTrajectoryOncePerTickAndAttributesFirstPlayerPassenger() {
var server = mock(Server.class);
when(server.getCurrentTick()).thenReturn(1, 2);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
var boat = mock(Boat.class);
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getType()).thenReturn(EntityType.BAMBOO_RAFT);
when(boat.isValid()).thenReturn(true);
when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 0.2, 80, 0));
var controller = mock(Player.class);
when(controller.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getPassengers()).thenReturn(List.of(controller));
when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat));
when(server.getWorlds()).thenReturn(List.of(world));
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
new AtomicLong(50_000_000L)::get, ignored -> SupportState.UNSUPPORTED);
observer.sample();
observer.sample();
assertEquals(1, observer.trackedBoatCount());
assertTrue(monitor.finishBefore(3, 150_000_000L).isEmpty());
verify(boat, never()).setVelocity(any());
verify(boat, never()).eject();
verify(boat, never()).remove();
}
@Test void lifecycleEventsReleaseBoatStateWithoutMutatingVehicle() {
var server = mock(Server.class);
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
System::nanoTime, ignored -> SupportState.UNSUPPORTED);
var boat = mock(Boat.class);
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
observer.onCollision(new org.bukkit.event.vehicle.VehicleBlockCollisionEvent(boat,
mock(org.bukkit.block.Block.class)));
observer.onTeleport(new org.bukkit.event.entity.EntityTeleportEvent(boat,
mock(Location.class), mock(Location.class)));
verify(boat, never()).eject();
verify(boat, never()).remove();
verify(boat, never()).teleport(any(Location.class));
}
@Test void controllerLossAndInvalidBoatReleaseEvidence() {
var server = mock(Server.class);
when(server.getCurrentTick()).thenReturn(1, 2);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
var boat = mock(Boat.class);
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getType()).thenReturn(EntityType.OAK_BOAT);
when(boat.isValid()).thenReturn(true, false);
when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0));
var controller = mock(Player.class);
when(controller.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getPassengers()).thenReturn(List.of(controller));
when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat));
when(server.getWorlds()).thenReturn(List.of(world));
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
System::nanoTime, ignored -> SupportState.UNSUPPORTED);
observer.sample();
observer.sample();
assertEquals(0, observer.trackedBoatCount());
assertTrue(monitor.inspect(controller.getUniqueId(), System.nanoTime()).isEmpty());
}
}
@@ -40,6 +40,49 @@ class BukkitMovementListenerTest {
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty(), name);
}
@Test void registeredMovementPathCapturesServerAuthoritativeGroundContext() {
var fixture = fixture();
fixture.groundContext.set(GroundMovementContext.sprinting(0.15, false));
for (int tick = 1; tick <= 3; tick++) {
when(fixture.server.getCurrentTick()).thenReturn(tick);
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 0.79, false));
fixture.monitor.finishBefore(tick + 1, (tick + 1) * 50_000_000L);
}
var evidence = fixture.monitor.inspect(fixture.player.getUniqueId(), 200_000_000L)
.orElseThrow().contextualEvidence();
assertEquals(3, evidence.breachCount());
assertEquals(DetectionSignal.CONTEXTUAL_GROUND_SPEED, evidence.signal());
assertTrue(evidence.expectedLimit() > 0.15);
}
@Test void speedAndSlownessAreModeledWhileOtherEffectsRemainExcluded() {
var fixture = fixture();
fixture.groundContext.set(GroundMovementContext.withEffects(0.1, false, false, true, 2, -1));
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 0.45, false));
fixture.monitor.finishBefore(2, 100_000_000L);
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty());
}
@Test void ambiguousNativeStatesExcludeContextualEvidence() {
for (var arrange : java.util.List.<java.util.function.Consumer<Fixture>>of(
f -> when(f.player.isInsideVehicle()).thenReturn(true),
f -> when(f.player.isGliding()).thenReturn(true),
f -> when(f.player.isSwimming()).thenReturn(true),
f -> when(f.player.isClimbing()).thenReturn(true),
f -> when(f.player.isRiptiding()).thenReturn(true),
f -> when(f.player.getAllowFlight()).thenReturn(true),
f -> when(f.server.getTPS()).thenReturn(new double[]{17.9, 20, 20}))) {
var fixture = fixture();
arrange.accept(fixture);
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 0.79, false));
fixture.monitor.finishBefore(2, 100_000_000L);
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty());
}
}
@Test void unsupportedSurvivalMovementFeedsFlightEvidenceWithoutTrustingFlyingAsAuthorization() {
var fixture = fixture();
when(fixture.player.isFlying()).thenReturn(true);
@@ -139,9 +182,10 @@ class BukkitMovementListenerTest {
when(player.getPose()).thenReturn(Pose.STANDING);
var monitor = new MovementMonitor(new MovementDetector());
var clock = new AtomicLong(50_000_000L);
var groundContext = new java.util.concurrent.atomic.AtomicReference<>(GroundMovementContext.walking(0.1));
var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get,
(from, to) -> true, location -> support);
return new Fixture(server, world, player, monitor, listener);
(from, to) -> true, location -> support, ignored -> groundContext.get());
return new Fixture(server, world, player, monitor, listener, groundContext);
}
private static PlayerMoveEvent move(Player player, World world, double from, double to, boolean cancelled) {
@@ -150,5 +194,7 @@ class BukkitMovementListenerTest {
return event;
}
private record Fixture(Server server, World world, Player player, MovementMonitor monitor, BukkitMovementListener listener) {}
private record Fixture(Server server, World world, Player player, MovementMonitor monitor,
BukkitMovementListener listener,
java.util.concurrent.atomic.AtomicReference<GroundMovementContext> groundContext) {}
}
@@ -0,0 +1,149 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
class ContextualGroundSpeedTest {
private static final UUID WORLD = UUID.randomUUID();
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
@Test void repeatedModerateOverspeedAlertsBelowAbsoluteThreshold() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var walking = GroundMovementContext.walking(0.1);
for (int tick = 1; tick <= 5; tick++) {
record(monitor, player, tick, 0.34, walking);
assertTrue(monitor.finishBefore(tick + 1, nanos(tick + 1)).isEmpty());
}
record(monitor, player, 6, 0.34, walking);
var alert = monitor.finishBefore(7, nanos(7)).getFirst();
assertEquals(DetectionSignal.CONTEXTUAL_GROUND_SPEED, alert.signal());
assertEquals(6, alert.breachCount());
assertEquals(0.34, alert.peakSpeed(), 1e-9);
assertEquals(0.22, alert.capturedLimit(), 1e-9);
assertEquals(0.34 / 0.22, alert.ratio(), 1e-9);
assertTrue(alert.peakSpeed() < SETTINGS.horizontalSpeedLimit());
}
@Test void ratioAndMinimumExcessMustBothBeExceeded() {
var monitor = new MovementMonitor(new MovementDetector());
var walking = GroundMovementContext.walking(0.1);
var player = UUID.randomUUID();
IntStream.rangeClosed(1, 10).forEach(tick -> {
record(monitor, player, tick, 0.30, walking); // Ratio exceeds 1.35, excess is only 0.08.
assertTrue(monitor.finishBefore(tick + 1, nanos(tick + 1)).isEmpty());
});
assertTrue(monitor.inspect(player, nanos(11)).isEmpty());
}
@Test void legalLocomotionContextsDoNotCreateEvidence() {
var contexts = new GroundMovementContext[] {
GroundMovementContext.walking(0.1),
GroundMovementContext.sprinting(0.1, true),
GroundMovementContext.sneaking(0.1),
GroundMovementContext.walking(0.15),
GroundMovementContext.withEffects(0.1, false, false, true, 1, -1),
GroundMovementContext.withEffects(0.1, false, false, true, -1, 1)
};
double[] legalSpeeds = {0.22, 0.37, 0.07, 0.33, 0.31, 0.16};
for (int index = 0; index < contexts.length; index++) {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
for (int tick = 1; tick <= 10; tick++) {
record(monitor, player, tick, legalSpeeds[index], contexts[index]);
assertTrue(monitor.finishBefore(tick + 1, nanos(tick + 1)).isEmpty());
}
assertTrue(monitor.inspect(player, nanos(11)).isEmpty(), "context " + index);
}
}
@Test void effectiveAttributeIsNotDoubleCountedWithStateMetadata() {
var context = new GroundMovementContext(0.13, 0.3, true, false, true, 1, -1);
assertEquals(0.13 * 2.2, context.expectedSpeed(), 1e-9);
}
@Test void everySupportedPotionAmplifierChangesTheExpectedEnvelope() {
for (int amplifier = 0; amplifier <= 4; amplifier++) {
double expected = 0.22 * (1.0 + 0.20 * (amplifier + 1));
assertEquals(expected,
GroundMovementContext.withEffects(0.1, false, false, true, amplifier, -1).expectedSpeed(),
1e-9, "speed amplifier " + amplifier);
}
for (int amplifier = 0; amplifier <= 3; amplifier++) {
double expected = 0.22 * Math.max(0.10, 1.0 - 0.15 * (amplifier + 1));
assertEquals(expected,
GroundMovementContext.withEffects(0.1, false, false, true, -1, amplifier).expectedSpeed(),
1e-9, "slowness amplifier " + amplifier);
}
}
@Test void absoluteSafeguardRemainsIndependentOfContextualEnvelope() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var fastAttribute = GroundMovementContext.walking(0.5);
for (int tick = 1; tick <= 6; tick++) {
record(monitor, player, tick, 0.90, fastAttribute);
}
var alerts = monitor.finishBefore(7, nanos(7));
assertEquals(1, alerts.size());
var alert = alerts.getFirst();
assertEquals(DetectionSignal.ABSOLUTE_GROUND_SPEED, alert.signal());
assertNull(monitor.inspect(player, nanos(7)).orElseThrow().contextualEvidence(),
"legal context remains legal while the independent absolute ceiling alerts");
assertEquals(0.80, alert.capturedLimit(), 1e-9);
}
@Test void exclusionClearsActiveContextWhilePreservingBoundedPriorEvidence() {
var detector = new MovementDetector();
var player = UUID.randomUUID();
var context = GroundMovementContext.walking(0.1);
detector.observe(player, new MovementSample(1, nanos(1), true, false, 0.34,
ExclusionReason.NONE, context), SETTINGS);
detector.observe(player, new MovementSample(2, nanos(2), true, false, 2.0,
ExclusionReason.ENVIRONMENT, null), SETTINGS);
var snapshot = detector.inspect(player, nanos(2)).orElseThrow().contextualEvidence();
assertEquals(1, snapshot.breachCount());
assertEquals(ExclusionReason.ENVIRONMENT, snapshot.currentExclusion());
}
@Test void oneSpikeDuplicateEventsBrokenChainsAndCleanupCannotInflateEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var context = GroundMovementContext.walking(0.1);
monitor.record(player, frame(1, 0, 0.34, context), SETTINGS);
monitor.record(player, frame(1, 0, 0.34, context), SETTINGS);
monitor.finishBefore(2, nanos(2));
assertEquals(1, monitor.inspect(player, nanos(2)).orElseThrow().contextualEvidence().breachCount());
monitor.record(player, frame(2, 0.34, 0.68, context), SETTINGS);
monitor.record(player, frame(2, 5, 5.34, context), SETTINGS);
monitor.finishBefore(3, nanos(3));
assertEquals(1, monitor.inspect(player, nanos(3)).orElseThrow().contextualEvidence().breachCount());
monitor.forget(player);
assertTrue(monitor.inspect(player, nanos(3)).isEmpty());
}
private static void record(MovementMonitor monitor, UUID player, int tick, double distance,
GroundMovementContext context) {
monitor.record(player, frame(tick, 0, distance, context), SETTINGS);
}
private static MovementFrame frame(int tick, double from, double to, GroundMovementContext context) {
return new MovementFrame(tick, nanos(tick), WORLD, WORLD, from, 0, to, 0,
ExclusionReason.NONE, context);
}
private static long nanos(int tick) {
return tick * 50_000_000L;
}
}
@@ -33,6 +33,12 @@ class DistributionTest {
assertTrue(config.contains("unsupported-hover-ticks: 30"));
assertTrue(config.contains("unsupported-ascent-blocks: 2.0"));
assertTrue(config.contains("unsupported-cruise-blocks: 10.0"));
assertTrue(config.contains("contextual-speed-ratio: 1.35"));
assertTrue(config.contains("contextual-minimum-excess: 0.10"));
assertTrue(config.contains("unsupported-boat-ticks: 40"));
assertTrue(config.contains("unsupported-boat-hover-ticks: 30"));
assertTrue(config.contains("unsupported-boat-ascent-blocks: 2.0"));
assertTrue(config.contains("unsupported-boat-cruise-blocks: 12.0"));
var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class");
assertNotNull(entry);
try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
@@ -0,0 +1,40 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.stream.Stream;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class MovementEnvironmentTest {
@ParameterizedTest(name = "{0}") @MethodSource("ambiguousMaterials")
void ambiguousMovementGeometryIsNeverOrdinary(Material material) {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var block = mock(Block.class);
when(block.getType()).thenReturn(material);
when(block.isLiquid()).thenReturn(material == Material.WATER || material == Material.LAVA);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
assertFalse(MovementEnvironment.isOrdinary(new Location(world, 0.5, 64, 0.5)));
}
@org.junit.jupiter.api.Test void unloadedGeometryIsUnknownRatherThanLegal() {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
assertFalse(MovementEnvironment.isOrdinary(new Location(world, 0.5, 64, 0.5)));
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
}
private static Stream<Material> ambiguousMaterials() {
return Stream.of(Material.ICE, Material.PACKED_ICE, Material.BLUE_ICE, Material.SLIME_BLOCK,
Material.HONEY_BLOCK, Material.SOUL_SAND, Material.SOUL_SOIL, Material.WATER,
Material.LADDER, Material.PISTON, Material.MOVING_PISTON);
}
}
@@ -30,6 +30,48 @@ class SentinelAlertsTest {
verify(server, never()).getConsoleSender();
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void contextualAlertNamesSignalExpectedLimitRatioAndWindowWithoutCoordinates() {
var server = mock(Server.class);
var subject = mock(Player.class);
var operator = mock(Player.class);
var id = UUID.randomUUID();
when(server.getPlayer(id)).thenReturn(subject);
when(subject.isOnline()).thenReturn(true);
when(subject.getName()).thenReturn("Alex");
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers();
new SentinelAlerts(server).send(AlertDecision.contextual(id, 6, 0.34, 0.22, 10));
verify(operator).sendMessage(argThat((String message) -> message.contains("CONTEXTUAL_GROUND_SPEED")
&& message.contains("0.34") && message.contains("0.22") && message.contains("1.55")
&& message.contains("6 breaches") && message.contains("10s window")
&& !message.contains("x=") && !message.contains("z=")
&& !message.toLowerCase().contains("cheat")));
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void boatAlertNamesControllerVehicleSignalAndBoundedEvidenceWithoutCoordinates() {
var server = mock(Server.class);
var subject = mock(Player.class);
var operator = mock(Player.class);
var id = UUID.randomUUID();
when(server.getPlayer(id)).thenReturn(subject);
when(subject.isOnline()).thenReturn(true);
when(subject.getName()).thenReturn("Alex");
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers();
var evidence = new BoatEvidence(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, UUID.randomUUID(),
"BAMBOO_RAFT", SupportState.UNSUPPORTED, 40, 0, 0, 40, 12, 0, 2, 60,
ExclusionReason.NONE);
new SentinelAlerts(server).send(AlertDecision.boat(id, evidence));
verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_CRUISE")
&& message.contains("BAMBOO_RAFT") && message.contains("40 unsupported ticks")
&& message.contains("12.00 horizontal") && !message.contains("x=")
&& !message.toLowerCase().contains("cheat")));
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void flightAlertNamesSignalAndBoundedDisplacementWithoutAccusingOrReportingCoordinates() {
var server = mock(Server.class);
@@ -35,6 +35,25 @@ class SentinelCommandTest {
verify(denied).sendMessage("You do not have permission to inspect Sentinel evidence.");
}
@Test void inspectDistinguishesContextualFromAbsoluteEvidence() throws Exception {
var fixture = fixture();
var target = online(fixture.server, "Runner");
var sender = permitted("sentinel.inspect");
var context = GroundMovementContext.walking(0.1);
var world = UUID.randomUUID();
for (int tick = 1; tick <= 6; tick++) {
fixture.monitor.record(target.getUniqueId(), new MovementFrame(tick, tick * 50_000_000L,
world, world, 0, 0, 0.34, 0, ExclusionReason.NONE, context), fixture.settings.current());
fixture.monitor.finishBefore(tick + 1, (tick + 1) * 50_000_000L);
}
fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Runner"});
verify(sender).sendMessage(argThat((String message) -> message.contains("CONTEXTUAL_GROUND_SPEED")
&& message.contains("6 breaches") && message.contains("observed 0.34")
&& message.contains("expected 0.22") && message.contains("ratio 1.55")
&& message.contains("exclusion none") && message.contains("cooldown")));
}
@Test void inspectDistinguishesUnknownOfflineAndNoCurrentEvidenceWithoutMutation() throws Exception {
var fixture = fixture();
var sender = permitted("sentinel.inspect");
@@ -52,6 +71,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");
@@ -91,6 +91,49 @@ class SettingsServiceTest {
() -> service.update(SettingKey.UNSUPPORTED_CRUISE_BLOCKS, "101"));
}
@Test void contextualThresholdsDefaultLoadPersistAndRemainBackwardCompatible() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals(1.35, service.current().contextualSpeedRatio(), 1e-9);
assertEquals(0.10, service.current().contextualMinimumExcess(), 1e-9);
assertEquals("1.50", service.update(SettingKey.CONTEXTUAL_SPEED_RATIO, "1.50"));
assertEquals("0.15", service.update(SettingKey.CONTEXTUAL_MINIMUM_EXCESS, "0.15"));
assertEquals(service.current(), SettingsService.load(new AtomicYamlSettingsStore(file)).current());
var old = directory.resolve("old.yml");
Files.writeString(old, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\n"
+ "evidence-window-seconds: 10\nalert-cooldown-seconds: 60\n"
+ "minimum-tps: 18.0\nexemption-grace-ticks: 20\n");
byte[] before = Files.readAllBytes(old);
var compatible = SettingsService.load(new AtomicYamlSettingsStore(old));
assertEquals(1.35, compatible.current().contextualSpeedRatio(), 1e-9);
assertEquals(0.10, compatible.current().contextualMinimumExcess(), 1e-9);
assertArrayEquals(before, Files.readAllBytes(old));
}
@Test void boatThresholdsDefaultLoadPersistAndRemainBackwardCompatible() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals(40, service.current().unsupportedBoatTicks());
assertEquals(30, service.current().unsupportedBoatHoverTicks());
assertEquals(2.0, service.current().unsupportedBoatAscentBlocks(), 1e-9);
assertEquals(12.0, service.current().unsupportedBoatCruiseBlocks(), 1e-9);
assertEquals("50", service.update(SettingKey.UNSUPPORTED_BOAT_TICKS, "50"));
assertEquals("15.0", service.update(SettingKey.UNSUPPORTED_BOAT_CRUISE_BLOCKS, "15"));
assertEquals(service.current(), SettingsService.load(new AtomicYamlSettingsStore(file)).current());
var old = directory.resolve("pre-boat.yml");
Files.writeString(old, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\n"
+ "evidence-window-seconds: 10\nalert-cooldown-seconds: 60\n"
+ "minimum-tps: 18.0\nexemption-grace-ticks: 20\n");
byte[] before = Files.readAllBytes(old);
var compatible = SettingsService.load(new AtomicYamlSettingsStore(old));
assertEquals(40, compatible.current().unsupportedBoatTicks());
assertArrayEquals(before, Files.readAllBytes(old));
}
@Test void malformedStartupConfigurationFailsBeforeUse() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);