Compare commits

..
12 Commits
Author SHA1 Message Date
dmg 1317b85835 feat(enforcement): add graduated movement responses
Release / release (push) Successful in 2m1s
CI / build (push) Successful in 1m13s
Closes SM-350.
2026-10-29 23:00:40 +00:00
dmg 2a2d307f6d feat(enforcement): add graduated movement responses
CI / build (push) Successful in 1m11s
CI / build (pull_request) Successful in 1m17s
2026-10-29 18:58:14 -04:00
dmg 8731548d5a feat(sentinel): persist alerts as CloudEvent JSONL
Release / release (push) Successful in 1m58s
CI / build (push) Successful in 1m11s
Merge PR #6 for Kaneo SM-349 after exact-candidate checks 3833 and 3834 passed.
2026-10-29 21:57:14 +00:00
dmg 4cebd19250 feat(sentinel): persist alerts as CloudEvent JSONL
CI / build (push) Successful in 1m18s
CI / build (pull_request) Successful in 1m17s
2026-10-29 17:55:18 -04:00
dmg e506cb79f0 feat(sentinel): detect elytra anomalies
Release / release (push) Successful in 1m54s
CI / build (push) Successful in 1m8s
Merge PR #5 for Kaneo SM-348 after exact-candidate checks 3825 and 3826 passed.
2026-10-29 21:10:50 +00:00
dmg 28e72e7349 feat(sentinel): detect elytra anomalies
CI / build (push) Successful in 1m12s
CI / build (pull_request) Successful in 1m16s
2026-10-29 17:09:00 -04:00
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
dmg 867b8bbaed feat(sentinel): detect unauthorized flight
Release / release (push) Successful in 1m49s
CI / build (push) Successful in 1m8s
2026-10-29 20:17:44 +00:00
dmg e15ff6549f feat(sentinel): detect unauthorized flight
CI / build (push) Successful in 1m18s
CI / build (pull_request) Successful in 1m17s
2026-10-29 16:14:33 -04:00
65 changed files with 4611 additions and 99 deletions

No files matched your search

+3 -1
View File
@@ -2,7 +2,9 @@
Passive, explainable movement-pattern observation for Purpur servers.
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement. It does not cancel movement, punish players, persist player evidence, or export telemetry.
Sentinel gives permitted operators bounded evidence of suspicious on-foot speed, unauthorized unsupported flight, unsupported boat travel, source-less elytra propulsion, and statistically implausible elytra wear. Elytra analysis correlates server-recognized gliding, equipped-item identity, observed rocket use, external propulsion, altitude conversion, wear opportunities, Unbreaking, Mending/XP repair, and unbreakable items; high speed alone is never evidence. Sentinel does not cancel movement, alter vehicles or items, consume rockets, change flight permissions, or punish players.
Each generated alert is also appended asynchronously as a CloudEvents 1.0 JSON object to `plugins/PurpurSentinel/events/YYYY-MM-DD.jsonl`. Dates and timestamps use UTC, files retain 30 UTC days, and durable events identify the subject by UUID without display names, coordinates, raw trajectories, inventory contents, IP addresses, or permission state. Queue or disk failures never interrupt detection or gameplay.
## Development
@@ -2,5 +2,56 @@ package games.dmg.purpursentinel;
import java.util.UUID;
public record AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) {
public record AlertDecision(
UUID playerId,
DetectionSignal signal,
int breachCount,
double peakSpeed,
double capturedLimit,
double ratio,
int evidenceWindowSeconds,
int eligibleTicks,
double horizontalDistance,
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, 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, 0, 0, evidence.eligibleTicks(),
evidence.horizontalDistance(), evidence.verticalDisplacement(), null);
}
public static AlertDecision boat(UUID playerId, BoatEvidence evidence) {
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(),
evidence.horizontalDistance(), evidence.verticalDisplacement(), evidence.vehicleType());
}
public static AlertDecision elytra(UUID playerId, ElytraEvidence evidence) {
return new AlertDecision(playerId, evidence.signal(), evidence.wearOpportunities(),
evidence.peakAcceleration(), evidence.unbreakingLevel(), 0, evidence.observedBoosts(),
evidence.eligibleTicks(), 0, evidence.ascent(), null);
}
}
@@ -0,0 +1,8 @@
package games.dmg.purpursentinel;
@FunctionalInterface
interface AlertEventSink {
AlertEventSink NONE = alert -> {};
void offer(AlertDecision alert);
}
@@ -29,6 +29,34 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence {
alert-cooldown-seconds: 60
minimum-tps: 18.0
exemption-grace-ticks: 20
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
enforcement-mode: off
enforcement-window-seconds: 600
enforcement-warning-episodes: 3
enforcement-setback-episodes: 5
enforcement-kick-episodes: 7
enforcement-hold-seconds: 10
enforcement-recent-damage-exclusion-seconds: 10
enforcement-signal-absolute-ground-speed: true
enforcement-signal-contextual-ground-speed: true
enforcement-signal-unauthorized-flight-state: true
enforcement-signal-unsupported-ascent: true
enforcement-signal-unsupported-hover: true
enforcement-signal-unsupported-cruise: true
enforcement-signal-elytra-sourceless-propulsion: true
enforcement-signal-unsupported-boat-ascent: false
enforcement-signal-unsupported-boat-hover: false
enforcement-signal-unsupported-boat-cruise: false
enforcement-signal-elytra-implausible-wear: false
""", StandardOpenOption.CREATE_NEW);
}
}
@@ -0,0 +1,194 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
final class BoatDetector {
private final Map<UUID, State> boats = new HashMap<>();
private final Map<UUID, UUID> controllerBoats = new HashMap<>();
Optional<AlertDecision> observe(BoatFrame frame, SentinelSettings settings) {
releaseOtherBoat(frame.controllerId(), frame.vehicleId());
var state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State());
if (state.controller != null && !state.controller.equals(frame.controllerId())) release(frame.vehicleId());
state = boats.computeIfAbsent(frame.vehicleId(), ignored -> new State());
controllerBoats.put(frame.controllerId(), frame.vehicleId());
state.controller = frame.controllerId();
state.vehicleId = frame.vehicleId();
state.vehicleType = frame.vehicleType();
state.expire(frame.observedAtNanos());
if (ineligible(frame)) {
state.resetSequence();
state.supportState = frame.supportState();
state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE
? ExclusionReason.ENVIRONMENT : frame.exclusionReason();
state.graceThroughTick = Math.max(state.graceThroughTick,
frame.tick() + settings.exemptionGraceTicks());
return Optional.empty();
}
if (frame.tick() <= state.graceThroughTick) return Optional.empty();
if (!state.connected(frame)) state.startSequence(frame);
state.lastTick = frame.tick();
state.supportState = SupportState.UNSUPPORTED;
state.currentExclusion = ExclusionReason.NONE;
double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
double vertical = frame.toY() - frame.fromY();
state.eligibleTicks++;
state.horizontalDistance += horizontal;
state.verticalDisplacement = frame.toY() - state.startY;
if (vertical > 0.01) state.ascentTicks++;
if (Math.abs(vertical) <= 0.01 && horizontal <= 0.03) state.hoverTicks++;
if (Math.abs(vertical) <= 0.01 && horizontal > 0.03) state.cruiseTicks++;
state.signal = signal(state, settings);
if (!thresholdReached(state, settings)) return Optional.empty();
state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds());
state.lastEvidence = state.evidence(frame.observedAtNanos());
state.evidenceFirstObservedAtNanos = state.firstObservedAtNanos;
if (frame.observedAtNanos() < state.cooldownUntilNanos) return Optional.empty();
state.cooldownUntilNanos = frame.observedAtNanos() + seconds(settings.alertCooldownSeconds());
state.lastEvidence = state.evidence(frame.observedAtNanos());
return Optional.of(AlertDecision.boat(frame.controllerId(), state.lastEvidence));
}
Optional<BoatEvidence> inspect(UUID playerId, long nowNanos) {
UUID boatId = controllerBoats.get(playerId);
if (boatId == null) return Optional.empty();
var state = boats.get(boatId);
if (state == null || !playerId.equals(state.controller)) return Optional.empty();
state.expire(nowNanos);
return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos));
}
void release(UUID boatId) {
var state = boats.remove(boatId);
if (state != null && state.controller != null) controllerBoats.remove(state.controller, boatId);
}
void forget(UUID playerId) {
var boatId = controllerBoats.remove(playerId);
if (boatId != null) boats.remove(boatId);
}
void cleanup(int currentTick, long nowNanos) {
var iterator = boats.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
var state = entry.getValue();
state.expire(nowNanos);
if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence();
if (state.world == null && state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos
&& currentTick > state.graceThroughTick) {
controllerBoats.remove(state.controller, entry.getKey());
iterator.remove();
}
}
}
void clear() { boats.clear(); controllerBoats.clear(); }
private void releaseOtherBoat(UUID controllerId, UUID currentBoat) {
var previous = controllerBoats.get(controllerId);
if (previous != null && !previous.equals(currentBoat)) release(previous);
}
private static boolean ineligible(BoatFrame frame) {
return frame.supportState() != SupportState.UNSUPPORTED
|| frame.exclusionReason() != ExclusionReason.NONE
|| !finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ());
}
private static DetectionSignal signal(State state, SentinelSettings settings) {
if (state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks()) {
return DetectionSignal.UNSUPPORTED_BOAT_ASCENT;
}
if (state.hoverTicks >= settings.unsupportedBoatHoverTicks()) {
return DetectionSignal.UNSUPPORTED_BOAT_HOVER;
}
return DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
}
private static boolean thresholdReached(State state, SentinelSettings settings) {
return switch (state.signal) {
case UNSUPPORTED_BOAT_ASCENT -> state.eligibleTicks >= settings.unsupportedBoatTicks()
&& state.verticalDisplacement + 1.0e-6 >= settings.unsupportedBoatAscentBlocks();
case UNSUPPORTED_BOAT_HOVER -> state.hoverTicks >= settings.unsupportedBoatHoverTicks();
case UNSUPPORTED_BOAT_CRUISE -> state.cruiseTicks >= settings.unsupportedBoatTicks()
&& state.horizontalDistance >= settings.unsupportedBoatCruiseBlocks();
default -> false;
};
}
private static long seconds(int value) { return value * 1_000_000_000L; }
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
}
private static final class State {
private UUID vehicleId;
private UUID controller;
private UUID world;
private String vehicleType = "UNKNOWN_BOAT";
private int lastTick = Integer.MIN_VALUE;
private int graceThroughTick = Integer.MIN_VALUE;
private long firstObservedAtNanos;
private long evidenceFirstObservedAtNanos;
private long evidenceExpiresAtNanos;
private long cooldownUntilNanos;
private double startY;
private int eligibleTicks;
private int ascentTicks;
private int hoverTicks;
private int cruiseTicks;
private double horizontalDistance;
private double verticalDisplacement;
private SupportState supportState = SupportState.UNKNOWN;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
private DetectionSignal signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
private BoatEvidence lastEvidence;
private boolean connected(BoatFrame frame) {
return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick();
}
private void startSequence(BoatFrame frame) {
resetSequence();
world = frame.world();
lastTick = frame.tick() - 1;
firstObservedAtNanos = frame.observedAtNanos();
startY = frame.fromY();
}
private void resetSequence() {
world = null;
lastTick = Integer.MIN_VALUE;
firstObservedAtNanos = 0;
startY = 0;
eligibleTicks = ascentTicks = hoverTicks = cruiseTicks = 0;
horizontalDistance = verticalDisplacement = 0;
signal = DetectionSignal.UNSUPPORTED_BOAT_CRUISE;
}
private void expire(long nowNanos) {
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
}
private BoatEvidence evidence(long nowNanos) {
var source = lastEvidence;
int ticks = eligibleTicks, climbs = ascentTicks, hovers = hoverTicks, cruises = cruiseTicks;
double horizontal = horizontalDistance, vertical = verticalDisplacement;
DetectionSignal currentSignal = signal;
long first = firstObservedAtNanos;
if (eligibleTicks == 0 && source != null) {
ticks = source.eligibleTicks(); climbs = source.ascentTicks(); hovers = source.hoverTicks();
cruises = source.cruiseTicks(); horizontal = source.horizontalDistance();
vertical = source.verticalDisplacement(); currentSignal = source.signal();
first = evidenceFirstObservedAtNanos;
}
return new BoatEvidence(currentSignal, vehicleId, vehicleType, supportState, ticks, climbs, hovers,
cruises, horizontal, vertical, Math.max(0, nowNanos - first) / 1_000_000_000.0,
Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0, currentExclusion);
}
}
}
@@ -0,0 +1,19 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record BoatEvidence(
DetectionSignal signal,
UUID vehicleId,
String vehicleType,
SupportState supportState,
int eligibleTicks,
int ascentTicks,
int hoverTicks,
int cruiseTicks,
double horizontalDistance,
double verticalDisplacement,
double evidenceAgeSeconds,
double cooldownRemainingSeconds,
ExclusionReason currentExclusion) {
}
@@ -0,0 +1,27 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record BoatFrame(
int tick,
long observedAtNanos,
UUID vehicleId,
UUID controllerId,
UUID world,
String vehicleType,
double fromX,
double fromY,
double fromZ,
double toX,
double toY,
double toZ,
SupportState supportState,
ExclusionReason exclusionReason) {
public static BoatFrame unsupported(int tick, long observedAtNanos, UUID vehicleId, UUID controllerId,
UUID world, String vehicleType, double fromX, double fromY, double fromZ,
double toX, double toY, double toZ) {
return new BoatFrame(tick, observedAtNanos, vehicleId, controllerId, world, vehicleType,
fromX, fromY, fromZ, toX, toY, toZ, SupportState.UNSUPPORTED, ExclusionReason.NONE);
}
}
@@ -0,0 +1,138 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Boat;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityTeleportEvent;
import org.bukkit.event.vehicle.VehicleBlockCollisionEvent;
import org.bukkit.event.vehicle.VehicleDestroyEvent;
import org.bukkit.event.vehicle.VehicleEnterEvent;
import org.bukkit.event.vehicle.VehicleExitEvent;
final class BukkitBoatObserver implements Listener {
private final Server server;
private final MovementMonitor monitor;
private final Supplier<SentinelSettings> settings;
private final LongSupplier nanoTime;
private final Function<Location, SupportState> supportState;
private final Map<UUID, Sample> lastSamples = new HashMap<>();
BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime, MovementEnvironment::boatSupportState);
}
BukkitBoatObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, Function<Location, SupportState> supportState) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
this.supportState = supportState;
}
void sample() {
int tick = server.getCurrentTick();
long observedAtNanos = nanoTime.getAsLong();
var seen = new HashSet<UUID>();
for (var world : server.getWorlds()) {
for (Boat boat : world.getEntitiesByClass(Boat.class)) {
if (!isBoatFamily(boat.getType()) || !boat.isValid() || boat.isDead()) {
release(boat.getUniqueId());
continue;
}
seen.add(boat.getUniqueId());
var controller = boat.getPassengers().stream().filter(Player.class::isInstance)
.map(Player.class::cast).findFirst().orElse(null);
if (controller == null) {
release(boat.getUniqueId());
continue;
}
Location current = boat.getLocation();
if (current == null || current.getWorld() == null) {
release(boat.getUniqueId());
continue;
}
Sample prior = lastSamples.get(boat.getUniqueId());
ExclusionReason reason = classify(boat, controller, current, prior);
Location previous = prior == null || !prior.world.equals(current.getWorld().getUID())
? current : prior.location;
monitor.record(new BoatFrame(tick, observedAtNanos, boat.getUniqueId(), controller.getUniqueId(),
current.getWorld().getUID(), boat.getType().name(), previous.getX(), previous.getY(), previous.getZ(),
current.getX(), current.getY(), current.getZ(), supportState.apply(current), reason), settings.get());
lastSamples.put(boat.getUniqueId(), new Sample(current.clone(), current.getWorld().getUID(),
controller.getUniqueId(), current.getBlockX() >> 4, current.getBlockZ() >> 4));
}
}
for (UUID boatId : new HashSet<>(lastSamples.keySet())) {
if (!seen.contains(boatId)) release(boatId);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEnter(VehicleEnterEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onExit(VehicleExitEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDestroy(VehicleDestroyEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR)
public void onCollision(VehicleBlockCollisionEvent event) { releaseIfBoat(event.getVehicle()); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(EntityTeleportEvent event) {
if (event.getEntity() instanceof Boat boat) release(boat.getUniqueId());
}
void clear() {
lastSamples.clear();
}
int trackedBoatCount() { return lastSamples.size(); }
static boolean isBoatFamily(EntityType type) {
String name = type.name();
return name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT")
|| name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT");
}
private ExclusionReason classify(Boat boat, Player controller, Location current, Sample prior) {
if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS;
if (prior == null || !prior.world.equals(current.getWorld().getUID())
|| !prior.controller.equals(controller.getUniqueId())
|| prior.chunkX != current.getBlockX() >> 4 || prior.chunkZ != current.getBlockZ() >> 4) {
return ExclusionReason.LIFECYCLE;
}
double displacement = Math.sqrt(prior.location.distanceSquared(current));
if (!Double.isFinite(displacement) || displacement > 8.0) return ExclusionReason.LIFECYCLE;
var velocity = boat.getVelocity();
if (velocity != null && (Math.abs(velocity.getX()) > 1.5 || Math.abs(velocity.getY()) > 1.0
|| Math.abs(velocity.getZ()) > 1.5)) return ExclusionReason.VELOCITY;
return ExclusionReason.NONE;
}
private void releaseIfBoat(org.bukkit.entity.Vehicle vehicle) {
if (vehicle instanceof Boat boat) release(boat.getUniqueId());
}
private void release(UUID boatId) {
lastSamples.remove(boatId);
monitor.releaseBoat(boatId);
}
private record Sample(Location location, UUID world, UUID controller, int chunkX, int chunkZ) {}
}
@@ -0,0 +1,175 @@
package games.dmg.purpursentinel;
import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityToggleGlideEvent;
import org.bukkit.event.player.PlayerExpChangeEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemDamageEvent;
import org.bukkit.event.player.PlayerItemMendEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRiptideEvent;
import org.bukkit.event.player.PlayerVelocityEvent;
import org.bukkit.inventory.ItemStack;
final class BukkitElytraObserver implements Listener {
private final Server server;
private final MovementMonitor monitor;
private final Supplier<SentinelSettings> settings;
private final LongSupplier nanoTime;
private final Function<ItemStack, ItemContext> itemContext;
private final Map<UUID, State> players = new HashMap<>();
BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime, BukkitElytraObserver::itemContext);
}
BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, Function<ItemStack, ItemContext> itemContext) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
this.itemContext = itemContext;
}
void sample() {
int tick = server.getCurrentTick();
long observedAtNanos = nanoTime.getAsLong();
for (Player player : server.getOnlinePlayers()) {
var state = players.computeIfAbsent(player.getUniqueId(), ignored -> new State());
if (player.getInventory() == null) {
reset(player.getUniqueId());
continue;
}
ItemStack chest = player.getInventory().getChestplate();
if (!player.isGliding() || chest == null || chest.getType() != Material.ELYTRA) {
reset(player.getUniqueId());
continue;
}
Location current = player.getLocation();
if (current == null || current.getWorld() == null) {
reset(player.getUniqueId());
continue;
}
if (state.elytraId == null) state.elytraId = UUID.randomUUID();
Location previous = state.location;
ExclusionReason reason = ExclusionReason.NONE;
if (previous == null || previous.getWorld() == null || !previous.getWorld().equals(current.getWorld())) {
previous = current;
reason = ExclusionReason.LIFECYCLE;
}
state.glideTicks++;
boolean opportunity = state.glideTicks % 20 == 0;
ItemContext item = itemContext.apply(chest);
monitor.record(player.getUniqueId(), new ElytraFrame(tick, observedAtNanos,
current.getWorld().getUID(), state.elytraId, true,
previous.getX(), previous.getY(), previous.getZ(), current.getX(), current.getY(), current.getZ(),
state.boost, state.riptide, state.velocity, state.explosion, opportunity,
state.wear, state.repair || state.experience, item.unbreaking(), item.unbreakable(), reason), settings.get());
state.location = current.clone();
state.clearSignals();
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
if (event.getItem() != null && event.getItem().getType() == Material.FIREWORK_ROCKET
&& event.getPlayer().isGliding()) state(event.getPlayer()).boost = true;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onRiptide(PlayerRiptideEvent event) { state(event.getPlayer()).riptide = true; }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onVelocity(PlayerVelocityEvent event) { state(event.getPlayer()).velocity = true; }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (event.getEntity() instanceof Player player
&& (event.getCause() == EntityDamageEvent.DamageCause.BLOCK_EXPLOSION
|| event.getCause() == EntityDamageEvent.DamageCause.ENTITY_EXPLOSION)) {
state(player).explosion = true;
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onWear(PlayerItemDamageEvent event) {
if (event.getItem().getType() == Material.ELYTRA && event.getDamage() > 0) state(event.getPlayer()).wear = true;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMend(PlayerItemMendEvent event) {
if (event.getItem().getType() == Material.ELYTRA) state(event.getPlayer()).repair = true;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onExperience(PlayerExpChangeEvent event) {
if (event.getAmount() > 0) state(event.getPlayer()).experience = true;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onArmorChange(PlayerArmorChangeEvent event) {
if (event.getSlotType() == PlayerArmorChangeEvent.SlotType.CHEST) reset(event.getPlayer().getUniqueId());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onToggleGlide(EntityToggleGlideEvent event) {
if (event.getEntity() instanceof Player player) reset(player.getUniqueId());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) { reset(event.getPlayer().getUniqueId()); }
void clear() { players.clear(); }
int trackedPlayerCount() { return players.size(); }
private static ItemContext itemContext(ItemStack item) {
int unbreaking = item.getEnchantmentLevel(Enchantment.UNBREAKING);
boolean unbreakable = item.getItemMeta() != null && item.getItemMeta().isUnbreakable();
return new ItemContext(unbreaking, unbreakable);
}
record ItemContext(int unbreaking, boolean unbreakable) {}
private State state(Player player) {
return players.computeIfAbsent(player.getUniqueId(), ignored -> new State());
}
private void reset(UUID playerId) {
players.remove(playerId);
monitor.forgetElytra(playerId);
}
private static final class State {
private UUID elytraId;
private Location location;
private int glideTicks;
private boolean boost;
private boolean riptide;
private boolean velocity;
private boolean explosion;
private boolean wear;
private boolean repair;
private boolean experience;
private void clearSignals() {
boost = riptide = velocity = explosion = wear = repair = experience = false;
}
}
}
@@ -0,0 +1,193 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.LongSupplier;
import java.util.function.Predicate;
import java.util.function.Supplier;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
final class BukkitEnforcement implements Listener, EnforcementRuntime {
private static final Component WARNING = Component.text(
"Sentinel detected repeated movement that the server could not validate. Please return to ordinary movement.");
private static final Component KICK = Component.text(
"Sentinel detected repeated movement that the server could not validate. You have been disconnected as a precaution.");
private final Server server;
private final Supplier<EnforcementSettings> settings;
private final LongSupplier nanoTime;
private final Predicate<Location> safeLocation;
private final EnforcementAuditSink audit;
private final EnforcementMonitor monitor;
private final Map<UUID, Location> anchors = new HashMap<>();
private final Map<UUID, Long> damagedAt = new HashMap<>();
private final Map<UUID, Hold> holds = new HashMap<>();
private UUID internalTeleport;
BukkitEnforcement(Server server, Supplier<EnforcementSettings> settings, LongSupplier nanoTime,
Predicate<Location> safeLocation, EnforcementAuditSink audit) {
this.server = server;
this.settings = settings;
this.nanoTime = nanoTime;
this.safeLocation = safeLocation;
this.audit = audit;
monitor = new EnforcementMonitor(audit);
}
@Override public void observe(AlertDecision alert) {
var current = settings.get();
if (current.mode() == EnforcementMode.OFF) return;
var player = server.getPlayer(alert.playerId());
Location anchor = anchors.get(alert.playerId());
boolean safe = player != null && anchor != null && anchor.getWorld() != null
&& player.getWorld().equals(anchor.getWorld()) && safeLocation.test(anchor);
long now = nanoTime.getAsLong();
Long lastDamage = damagedAt.get(alert.playerId());
boolean recentDamage = lastDamage != null && now - lastDamage
<= seconds(current.recentDamageExclusionSeconds());
var decision = monitor.observe(alert.playerId(), alert.signal(), now, server.getCurrentTick(), current, safe,
recentDamage);
if (decision.isNone()) return;
if (player == null || !player.isOnline()) {
if (decision.action() != EnforcementAction.SKIPPED) audit(EnforcementAction.SKIPPED, alert, decision, "player-offline");
return;
}
switch (decision.action()) {
case WARNING -> player.sendMessage(WARNING);
case SETBACK -> {
Location target = anchor.clone();
Location currentLocation = player.getLocation();
if (currentLocation != null) {
target.setYaw(currentLocation.getYaw());
target.setPitch(currentLocation.getPitch());
}
internalTeleport = alert.playerId();
boolean moved;
try {
moved = player.teleport(target);
} finally {
internalTeleport = null;
}
if (moved) holds.put(alert.playerId(),
new Hold(target.clone(), now + seconds(decision.holdSeconds()), alert.signal()));
else audit(EnforcementAction.SKIPPED, alert, decision, "teleport-refused");
}
case KICK -> player.kick(KICK);
case NONE, SKIPPED, HOLD_ENDED, OPERATOR_RESET -> { }
}
}
void captureSafeAnchor(Player player, Location location) {
var current = settings.get();
if (!current.mode().setsBack() || location.getWorld() == null || !safeLocation.test(location)) return;
UUID playerId = player.getUniqueId();
if (holds.containsKey(playerId) || !monitor.inspect(playerId, nanoTime.getAsLong(), current).isEmpty()) return;
anchors.put(playerId, location.clone());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void onMove(PlayerMoveEvent event) {
var hold = holds.get(event.getPlayer().getUniqueId());
if (hold == null) return;
long now = nanoTime.getAsLong();
if (now >= hold.untilNanos) {
endHold(event.getPlayer().getUniqueId(), "expired");
return;
}
Location to = event.getTo();
if (to == null || to.getWorld() == null || !to.getWorld().equals(hold.anchor.getWorld())) {
endHold(event.getPlayer().getUniqueId(), "external-movement");
return;
}
Location fixed = hold.anchor.clone();
fixed.setYaw(to.getYaw());
fixed.setPitch(to.getPitch());
event.setTo(fixed);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (event.getEntity() instanceof Player player) recordDamage(player);
}
void recordDamage(Player player) {
long now = nanoTime.getAsLong();
damagedAt.put(player.getUniqueId(), now);
endHold(player.getUniqueId(), "damage");
}
@EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { endHold(event.getPlayer().getUniqueId(), "disconnect"); }
@EventHandler(priority = EventPriority.MONITOR) public void onDeath(PlayerDeathEvent event) { endHold(event.getEntity().getUniqueId(), "death"); }
@EventHandler(priority = EventPriority.MONITOR) public void onRespawn(PlayerRespawnEvent event) { endHold(event.getPlayer().getUniqueId(), "respawn"); }
@EventHandler(priority = EventPriority.MONITOR) public void onWorldChange(PlayerChangedWorldEvent event) { endHold(event.getPlayer().getUniqueId(), "world-change"); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTeleport(PlayerTeleportEvent event) {
if (!event.getPlayer().getUniqueId().equals(internalTeleport)) {
endHold(event.getPlayer().getUniqueId(), "external-teleport");
}
}
@Override public void reset(UUID playerId) {
monitor.reset(playerId);
anchors.remove(playerId);
damagedAt.remove(playerId);
var prior = holds.remove(playerId);
audit.offer(new EnforcementAudit(playerId, prior == null ? null : prior.signal, EnforcementAction.OPERATOR_RESET,
0, settings.get().windowSeconds(), 0, "operator-reset"));
}
@Override public List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos) {
return monitor.inspect(playerId, nowNanos, settings.get());
}
@Override public double holdRemainingSeconds(UUID playerId, long nowNanos) {
var hold = holds.get(playerId);
if (hold == null) return 0;
if (hold.untilNanos <= nowNanos) {
endHold(playerId, "expired");
return 0;
}
return (hold.untilNanos - nowNanos) / 1_000_000_000.0;
}
@Override public void policyChanged() {
monitor.clear();
anchors.clear();
damagedAt.clear();
for (UUID playerId : List.copyOf(holds.keySet())) endHold(playerId, "policy-changed");
}
void clear() {
monitor.clear();
anchors.clear();
damagedAt.clear();
for (UUID playerId : List.copyOf(holds.keySet())) endHold(playerId, "plugin-disable");
}
private void endHold(UUID playerId, String reason) {
var prior = holds.remove(playerId);
if (prior != null) audit.offer(new EnforcementAudit(playerId, prior.signal, EnforcementAction.HOLD_ENDED,
0, settings.get().windowSeconds(), 0, reason));
}
private void audit(EnforcementAction action, AlertDecision alert, EnforcementDecision decision, String reason) {
audit.offer(new EnforcementAudit(alert.playerId(), alert.signal(), action, decision.episodeCount(),
decision.windowSeconds(), decision.holdSeconds(), reason));
}
private static long seconds(int value) { return value * 1_000_000_000L; }
private record Hold(Location anchor, long untilNanos, DetectionSignal signal) { }
}
@@ -1,11 +1,16 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.BiPredicate;
import java.util.function.Function;
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;
@@ -28,19 +33,49 @@ public final class BukkitMovementListener implements Listener {
private final Supplier<SentinelSettings> settings;
private final LongSupplier nanoTime;
private final BiPredicate<Location, Location> ordinaryEnvironment;
private final Function<Location, SupportState> supportState;
private final Function<Player, GroundMovementContext> groundMovementContext;
private final BukkitEnforcement enforcement;
private final Map<UUID, Location> lastFlightSamples = new HashMap<>();
public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime,
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to));
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to),
MovementEnvironment::supportState, BukkitMovementListener::groundContext, null);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState,
BukkitMovementListener::groundContext, null);
}
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, null);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState,
Function<Player, GroundMovementContext> groundMovementContext) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState, groundMovementContext, null);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState,
Function<Player, GroundMovementContext> groundMovementContext, BukkitEnforcement enforcement) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.nanoTime = nanoTime;
this.ordinaryEnvironment = ordinaryEnvironment;
this.supportState = supportState;
this.groundMovementContext = groundMovementContext;
this.enforcement = enforcement;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
@@ -50,12 +85,14 @@ public final class BukkitMovementListener implements Listener {
Location to = event.getTo();
var player = event.getPlayer();
ExclusionReason reason = classify(player, from, to, event.isCancelled());
if (reason == ExclusionReason.NONE && enforcement != null) enforcement.captureSafeAnchor(player, from);
monitor.record(player.getUniqueId(), new MovementFrame(
server.getCurrentTick(),
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)
@@ -84,9 +121,36 @@ public final class BukkitMovementListener implements Listener {
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) { monitor.forget(event.getPlayer().getUniqueId()); }
public void onQuit(PlayerQuitEvent event) {
lastFlightSamples.remove(event.getPlayer().getUniqueId());
monitor.forget(event.getPlayer().getUniqueId());
}
public void sampleFlight() {
int tick = server.getCurrentTick();
long observedAtNanos = nanoTime.getAsLong();
for (Player player : server.getOnlinePlayers()) {
Location current = player.getLocation();
if (current == null || current.getWorld() == null) continue;
Location previous = lastFlightSamples.put(player.getUniqueId(), current.clone());
if (previous == null || previous.getWorld() == null || !previous.getWorld().equals(current.getWorld())) {
previous = current;
}
ExclusionReason reason = classify(player, previous, current, false);
monitor.record(player.getUniqueId(), new FlightFrame(
tick, observedAtNanos, current.getWorld().getUID(),
previous.getX(), previous.getY(), previous.getZ(),
current.getX(), current.getY(), current.getZ(),
authorizedFlight(player), player.isFlying(), supportState.apply(current), reason), settings.get());
}
}
public void clear() {
lastFlightSamples.clear();
}
private void exempt(Player player, ExclusionReason reason) {
lastFlightSamples.remove(player.getUniqueId());
monitor.exempt(player.getUniqueId(), server.getCurrentTick(), nanoTime.getAsLong(), reason, settings.get());
}
@@ -97,20 +161,45 @@ public final class BukkitMovementListener implements Listener {
if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS;
if (player.getGameMode() != GameMode.SURVIVAL && player.getGameMode() != GameMode.ADVENTURE) return ExclusionReason.GAME_MODE;
if (player.isInsideVehicle()) return ExclusionReason.VEHICLE;
if (player.isFlying()) return ExclusionReason.FLYING;
if (player.getAllowFlight()) return ExclusionReason.FLYING;
if (player.isGliding() || player.getPose() == Pose.FALL_FLYING) return ExclusionReason.GLIDING;
if (player.isSwimming() || player.getPose() == Pose.SWIMMING || player.isInWater()
|| player.isInLava() || player.isInBubbleColumn()) return ExclusionReason.SWIMMING;
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"))) {
.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
|| player.getAllowFlight();
}
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
@@ -0,0 +1,311 @@
package games.dmg.purpursentinel;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
final class CloudEventLog implements AlertEventSink, EnforcementAuditSink, AutoCloseable {
static final int DEFAULT_QUEUE_CAPACITY = 1_024;
static final int RETENTION_DAYS = 30;
private static final Duration WARNING_INTERVAL = Duration.ofMinutes(1);
private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(5);
private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private final Clock clock;
private final Consumer<String> warning;
private final ArrayBlockingQueue<Event> queue;
private final AtomicBoolean accepting = new AtomicBoolean(true);
private final Object lifecycleLock = new Object();
private final Duration shutdownTimeout;
private final EventStore store;
private final Thread worker;
private long nextWarningAtMillis;
CloudEventLog(Path directory, Clock clock, Consumer<String> warning) {
this(clock, warning, DEFAULT_QUEUE_CAPACITY, SHUTDOWN_TIMEOUT, new DailyEventStore(directory));
}
CloudEventLog(Clock clock, Consumer<String> warning, int capacity, Duration shutdownTimeout, EventStore store) {
if (capacity < 1) throw new IllegalArgumentException("capacity must be positive");
this.clock = clock;
this.warning = warning;
this.shutdownTimeout = shutdownTimeout;
this.store = store;
queue = new ArrayBlockingQueue<>(capacity);
worker = Thread.ofPlatform().name("purpur-sentinel-event-log").daemon(true).start(this::run);
}
@Override public void offer(AlertDecision alert) { offer(Event.alert(clock.instant(), alert)); }
@Override public void offer(EnforcementAudit audit) { offer(Event.audit(clock.instant(), audit)); }
private void offer(Event event) {
boolean dropped;
synchronized (lifecycleLock) {
if (!accepting.get()) return;
dropped = !queue.offer(event);
}
if (dropped) warn("Sentinel event log queue is full; one durable event was dropped.");
}
@Override public void close() {
synchronized (lifecycleLock) {
if (!accepting.getAndSet(false)) return;
}
worker.interrupt();
try {
worker.join(shutdownTimeout.toMillis());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
if (worker.isAlive()) warn("Sentinel event log did not finish its bounded shutdown drain.");
queue.clear();
}
int queuedEventCount() { return queue.size(); }
private void run() {
try {
while (accepting.get() || !queue.isEmpty()) {
Event event;
try {
event = queue.poll(250, TimeUnit.MILLISECONDS);
} catch (InterruptedException exception) {
continue;
}
if (event == null) continue;
try {
store.append(event.observedAt, event.json());
} catch (RetentionException exception) {
warn("Sentinel could not apply event log retention; detection remains active.");
} catch (IOException | RuntimeException exception) {
warn("Sentinel could not write a durable alert; detection remains active.");
}
}
} finally {
try {
store.close();
} catch (IOException exception) {
warn("Sentinel could not close its event log cleanly.");
}
}
}
private static Writer open(Path directory, LocalDate date) throws IOException {
Files.createDirectories(directory);
Path file = directory.resolve(FILE_DATE.format(date) + ".jsonl");
if (Files.exists(file)) truncatePartialTail(file);
return new Writer(date, Files.newBufferedWriter(file, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND));
}
private static void truncatePartialTail(Path file) throws IOException {
try (var channel = java.nio.channels.FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
long size = channel.size();
if (size == 0) return;
var buffer = java.nio.ByteBuffer.allocate(8_192);
long end = size;
while (end > 0) {
int length = (int) Math.min(buffer.capacity(), end);
long start = end - length;
buffer.clear().limit(length);
channel.position(start);
while (buffer.hasRemaining() && channel.read(buffer) >= 0) { }
byte[] bytes = buffer.array();
for (int index = length - 1; index >= 0; index--) {
if (bytes[index] == '\n' || bytes[index] == '\r') {
if (start + index + 1 < size) channel.truncate(start + index + 1);
return;
}
}
end = start;
}
channel.truncate(0);
}
}
private static void retain(Path directory, LocalDate currentDate) throws IOException {
LocalDate oldest = currentDate.minusDays(RETENTION_DAYS - 1L);
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*.jsonl")) {
for (Path file : files) {
String name = file.getFileName().toString();
if (!name.matches("\\d{4}-\\d{2}-\\d{2}\\.jsonl")) continue;
try {
LocalDate date = LocalDate.parse(name.substring(0, 10), FILE_DATE);
if (date.isBefore(oldest)) Files.deleteIfExists(file);
} catch (DateTimeParseException ignored) { }
}
}
}
private static String toJson(Instant observedAt, AlertDecision alert) {
String type = alert.signal().name().toLowerCase(Locale.ROOT).replace('_', '-');
StringBuilder data = new StringBuilder(256);
data.append("{\"signal\":\"").append(alert.signal()).append('\"');
switch (alert.signal()) {
case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED -> data
.append(",\"breachCount\":").append(alert.breachCount())
.append(",\"observedSpeed\":").append(number(alert.peakSpeed()))
.append(",\"capturedLimit\":").append(number(alert.capturedLimit()))
.append(",\"ratio\":").append(number(alert.ratio()))
.append(",\"evidenceWindowSeconds\":").append(alert.evidenceWindowSeconds());
case UNAUTHORIZED_FLIGHT_STATE, UNSUPPORTED_ASCENT, UNSUPPORTED_HOVER,
UNSUPPORTED_CRUISE -> appendTravel(data, alert);
case UNSUPPORTED_BOAT_ASCENT, UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE -> {
appendTravel(data, alert);
String vehicleType = alert.vehicleType() == null ? "UNKNOWN" : alert.vehicleType();
data.append(",\"vehicleType\":\"").append(escape(vehicleType)).append('\"');
}
case ELYTRA_SOURCELESS_PROPULSION, ELYTRA_IMPLAUSIBLE_WEAR -> data
.append(",\"eligibleTicks\":").append(alert.eligibleTicks())
.append(",\"ascent\":").append(number(alert.verticalDisplacement()))
.append(",\"peakAcceleration\":").append(number(alert.peakSpeed()))
.append(",\"observedBoosts\":").append(alert.evidenceWindowSeconds())
.append(",\"wearOpportunities\":").append(alert.breachCount())
.append(",\"unbreakingLevel\":").append((int) alert.capturedLimit());
}
data.append('}');
return "{\"specversion\":\"1.0\",\"id\":\"" + UUID.randomUUID()
+ "\",\"source\":\"urn:somc:purpur-sentinel\",\"type\":\"games.dmg.purpursentinel.alert."
+ type + "\",\"subject\":\"" + alert.playerId() + "\",\"time\":\""
+ DateTimeFormatter.ISO_INSTANT.format(observedAt.truncatedTo(ChronoUnit.MILLIS))
+ "\",\"datacontenttype\":\"application/json\",\"data\":" + data + '}';
}
private static String toJson(Instant observedAt, EnforcementAudit audit) {
String type = audit.action().name().toLowerCase(Locale.ROOT).replace('_', '-');
return "{\"specversion\":\"1.0\",\"id\":\"" + UUID.randomUUID()
+ "\",\"source\":\"urn:somc:purpur-sentinel\",\"type\":\"games.dmg.purpursentinel.enforcement."
+ type + "\",\"subject\":\"" + audit.playerId() + "\",\"time\":\""
+ DateTimeFormatter.ISO_INSTANT.format(observedAt.truncatedTo(ChronoUnit.MILLIS))
+ "\",\"datacontenttype\":\"application/json\",\"data\":{\"signal\":\""
+ audit.signal() + "\",\"action\":\"" + audit.action() + "\",\"episodeCount\":"
+ audit.episodeCount() + ",\"windowSeconds\":" + audit.windowSeconds()
+ ",\"durationSeconds\":" + audit.durationSeconds() + ",\"reason\":\""
+ escape(audit.reason()) + "\"}}";
}
private static void appendTravel(StringBuilder data, AlertDecision alert) {
data.append(",\"eligibleTicks\":").append(alert.eligibleTicks())
.append(",\"horizontalDistance\":").append(number(alert.horizontalDistance()))
.append(",\"verticalDisplacement\":").append(number(alert.verticalDisplacement()));
}
private static String number(double value) {
return Double.isFinite(value) ? Double.toString(value) : "0.0";
}
private static String escape(String value) {
StringBuilder escaped = new StringBuilder(value.length() + 8);
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '\"' -> escaped.append("\\\"");
case '\\' -> escaped.append("\\\\");
case '\b' -> escaped.append("\\b");
case '\f' -> escaped.append("\\f");
case '\n' -> escaped.append("\\n");
case '\r' -> escaped.append("\\r");
case '\t' -> escaped.append("\\t");
default -> {
if (character < 0x20) escaped.append(String.format(Locale.ROOT, "\\u%04x", (int) character));
else escaped.append(character);
}
}
}
return escaped.toString();
}
private synchronized void warn(String message) {
long now = clock.millis();
if (now < nextWarningAtMillis) return;
nextWarningAtMillis = now + WARNING_INTERVAL.toMillis();
try {
warning.accept(message);
} catch (RuntimeException ignored) {
// A logging backend failure must not stop durable event processing.
}
}
@FunctionalInterface
interface EventStore extends AutoCloseable {
void append(Instant observedAt, String json) throws IOException;
@Override default void close() throws IOException { }
}
private static final class DailyEventStore implements EventStore {
private final Path directory;
private Writer writer;
private DailyEventStore(Path directory) { this.directory = directory; }
@Override public void append(Instant observedAt, String json) throws IOException {
LocalDate date = observedAt.atZone(ZoneOffset.UTC).toLocalDate();
IOException retentionFailure = null;
try {
if (writer == null || !writer.date.equals(date)) {
close();
writer = open(directory, date);
try {
retain(directory, date);
} catch (IOException exception) {
retentionFailure = exception;
}
}
writer.output.write(json);
writer.output.newLine();
writer.output.flush();
} catch (IOException exception) {
try { close(); } catch (IOException closeFailure) { exception.addSuppressed(closeFailure); }
throw exception;
}
if (retentionFailure != null) warnRetention(retentionFailure);
}
private static void warnRetention(IOException failure) throws RetentionException {
throw new RetentionException(failure);
}
@Override public void close() throws IOException {
if (writer == null) return;
try { writer.close(); } finally { writer = null; }
}
}
static final class RetentionException extends IOException {
private static final long serialVersionUID = 1L;
RetentionException(IOException cause) { super(cause); }
}
private record Event(Instant observedAt, AlertDecision alert, EnforcementAudit audit) {
private static Event alert(Instant observedAt, AlertDecision alert) {
return new Event(observedAt, alert, null);
}
private static Event audit(Instant observedAt, EnforcementAudit audit) {
return new Event(observedAt, null, audit);
}
private String json() {
return alert != null ? toJson(observedAt, alert) : toJson(observedAt, audit);
}
}
private record Writer(LocalDate date, BufferedWriter output) implements AutoCloseable {
@Override public void close() throws IOException { output.close(); }
}
}
@@ -0,0 +1,15 @@
package games.dmg.purpursentinel;
public enum DetectionSignal {
ABSOLUTE_GROUND_SPEED,
CONTEXTUAL_GROUND_SPEED,
UNAUTHORIZED_FLIGHT_STATE,
UNSUPPORTED_ASCENT,
UNSUPPORTED_HOVER,
UNSUPPORTED_CRUISE,
UNSUPPORTED_BOAT_ASCENT,
UNSUPPORTED_BOAT_HOVER,
UNSUPPORTED_BOAT_CRUISE,
ELYTRA_SOURCELESS_PROPULSION,
ELYTRA_IMPLAUSIBLE_WEAR
}
@@ -0,0 +1,136 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
final class ElytraDetector {
private static final int SOURCELESS_TICKS_TO_ALERT = 6;
private static final double ASCENT_PER_TICK = 0.10;
private static final double ACCELERATION_PER_TICK = 0.12;
private final Map<UUID, State> players = new HashMap<>();
Optional<AlertDecision> observe(UUID playerId, ElytraFrame frame, SentinelSettings settings) {
var state = players.computeIfAbsent(playerId, ignored -> new State());
state.expire(frame.observedAtNanos());
if (!eligible(frame) || !frame.elytraId().equals(state.elytraId) && state.elytraId != null) {
state.reset(frame.elytraId());
state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE
? ExclusionReason.LIFECYCLE : frame.exclusionReason();
return Optional.empty();
}
if (state.elytraId == null) state.reset(frame.elytraId());
if (state.world != null && (!state.world.equals(frame.world()) || state.lastTick + 1 != frame.tick())) {
state.reset(frame.elytraId());
}
state.world = frame.world();
state.lastTick = frame.tick();
state.currentExclusion = ExclusionReason.NONE;
if (state.firstObservedAtNanos == 0) state.firstObservedAtNanos = frame.observedAtNanos();
state.eligibleTicks++;
double horizontalSpeed = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
double acceleration = horizontalSpeed - state.lastHorizontalSpeed;
double vertical = frame.toY() - frame.fromY();
state.ascent += Math.max(0, vertical);
state.peakAcceleration = Math.max(state.peakAcceleration, acceleration);
boolean source = frame.observedBoost() || frame.riptide() || frame.externalVelocity() || frame.explosion();
if (source) state.propulsionGraceThroughTick = frame.tick() + 20;
if (frame.observedBoost()) state.observedBoosts++;
if (frame.tick() <= state.propulsionGraceThroughTick || vertical < -0.05) {
state.sourcelessTicks = 0;
} else if (vertical > ASCENT_PER_TICK || acceleration > ACCELERATION_PER_TICK) {
state.sourcelessTicks++;
}
state.lastHorizontalSpeed = horizontalSpeed;
if (frame.unbreakable() || frame.repairObserved() || frame.wearObserved()) {
state.wearOpportunities = 0;
} else if (frame.wearOpportunity()) {
state.wearOpportunities++;
}
state.unbreakingLevel = frame.unbreakingLevel();
DetectionSignal signal = null;
if (state.sourcelessTicks >= SOURCELESS_TICKS_TO_ALERT) {
signal = DetectionSignal.ELYTRA_SOURCELESS_PROPULSION;
} else if (!frame.unbreakable() && state.wearOpportunities >= wearThreshold(frame.unbreakingLevel())) {
signal = DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR;
}
if (signal == null) return Optional.empty();
state.signal = signal;
state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds());
state.lastEvidence = state.evidence(frame.observedAtNanos());
if (frame.observedAtNanos() < state.cooldownUntilNanos) return Optional.empty();
state.cooldownUntilNanos = frame.observedAtNanos() + seconds(settings.alertCooldownSeconds());
state.lastEvidence = state.evidence(frame.observedAtNanos());
return Optional.of(AlertDecision.elytra(playerId, state.lastEvidence));
}
Optional<ElytraEvidence> inspect(UUID playerId, long nowNanos) {
var state = players.get(playerId);
if (state == null) return Optional.empty();
state.expire(nowNanos);
return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos));
}
void forget(UUID playerId) { players.remove(playerId); }
void clear() { players.clear(); }
void cleanup(long nowNanos) {
players.entrySet().removeIf(entry -> {
var state = entry.getValue();
state.expire(nowNanos);
return state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos && !state.active();
});
}
private static boolean eligible(ElytraFrame frame) {
return frame.gliding() && frame.exclusionReason() == ExclusionReason.NONE
&& finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ());
}
private static int wearThreshold(int unbreakingLevel) { return 60 * Math.max(1, unbreakingLevel + 1); }
private static long seconds(int value) { return value * 1_000_000_000L; }
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
}
private static final class State {
private UUID world;
private UUID elytraId;
private int lastTick = Integer.MIN_VALUE;
private long firstObservedAtNanos;
private long evidenceExpiresAtNanos;
private long cooldownUntilNanos;
private int eligibleTicks;
private int sourcelessTicks;
private int observedBoosts;
private int wearOpportunities;
private int unbreakingLevel;
private int propulsionGraceThroughTick = Integer.MIN_VALUE;
private double ascent;
private double peakAcceleration;
private double lastHorizontalSpeed;
private DetectionSignal signal = DetectionSignal.ELYTRA_SOURCELESS_PROPULSION;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
private ElytraEvidence lastEvidence;
private boolean active() { return world != null && eligibleTicks > 0; }
private void reset(UUID replacement) {
world = null; elytraId = replacement; lastTick = Integer.MIN_VALUE; firstObservedAtNanos = 0;
eligibleTicks = sourcelessTicks = observedBoosts = wearOpportunities = unbreakingLevel = 0;
propulsionGraceThroughTick = Integer.MIN_VALUE;
ascent = peakAcceleration = lastHorizontalSpeed = 0;
}
private void expire(long nowNanos) {
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
}
private ElytraEvidence evidence(long nowNanos) {
return new ElytraEvidence(signal, eligibleTicks, sourcelessTicks, observedBoosts, wearOpportunities,
unbreakingLevel, ascent, peakAcceleration,
Math.max(0, nowNanos - firstObservedAtNanos) / 1_000_000_000.0,
Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0, currentExclusion);
}
}
}
@@ -0,0 +1,15 @@
package games.dmg.purpursentinel;
public record ElytraEvidence(
DetectionSignal signal,
int eligibleTicks,
int sourcelessTicks,
int observedBoosts,
int wearOpportunities,
int unbreakingLevel,
double ascent,
double peakAcceleration,
double evidenceAgeSeconds,
double cooldownRemainingSeconds,
ExclusionReason currentExclusion) {
}
@@ -0,0 +1,27 @@
package games.dmg.purpursentinel;
import java.util.UUID;
public record ElytraFrame(
int tick,
long observedAtNanos,
UUID world,
UUID elytraId,
boolean gliding,
double fromX,
double fromY,
double fromZ,
double toX,
double toY,
double toZ,
boolean observedBoost,
boolean riptide,
boolean externalVelocity,
boolean explosion,
boolean wearOpportunity,
boolean wearObserved,
boolean repairObserved,
int unbreakingLevel,
boolean unbreakable,
ExclusionReason exclusionReason) {
}
@@ -0,0 +1,11 @@
package games.dmg.purpursentinel;
enum EnforcementAction {
NONE,
WARNING,
SETBACK,
KICK,
SKIPPED,
HOLD_ENDED,
OPERATOR_RESET
}
@@ -0,0 +1,13 @@
package games.dmg.purpursentinel;
import java.util.UUID;
record EnforcementAudit(
UUID playerId,
DetectionSignal signal,
EnforcementAction action,
int episodeCount,
int windowSeconds,
int durationSeconds,
String reason) {
}
@@ -0,0 +1,8 @@
package games.dmg.purpursentinel;
@FunctionalInterface
interface EnforcementAuditSink {
EnforcementAuditSink NONE = audit -> { };
void offer(EnforcementAudit audit);
}
@@ -0,0 +1,16 @@
package games.dmg.purpursentinel;
record EnforcementDecision(
EnforcementAction action,
DetectionSignal signal,
int episodeCount,
int windowSeconds,
int holdSeconds,
String reason) {
static EnforcementDecision none() {
return new EnforcementDecision(EnforcementAction.NONE, null, 0, 0, 0, "none");
}
boolean isNone() { return action == EnforcementAction.NONE; }
}
@@ -0,0 +1,26 @@
package games.dmg.purpursentinel;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
public enum EnforcementMode {
OFF("off"),
WARN("warn"),
SETBACK("setback"),
KICK("kick"),
SETBACK_KICK("setback-kick");
private final String value;
EnforcementMode(String value) { this.value = value; }
String value() { return value; }
boolean warns() { return this == WARN || this == SETBACK || this == SETBACK_KICK; }
boolean setsBack() { return this == SETBACK || this == SETBACK_KICK; }
boolean kicks() { return this == KICK || this == SETBACK_KICK; }
static Optional<EnforcementMode> find(String value) {
return Arrays.stream(values()).filter(mode -> mode.value.equals(value.toLowerCase(Locale.ROOT))).findFirst();
}
}
@@ -0,0 +1,105 @@
package games.dmg.purpursentinel;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
final class EnforcementMonitor {
private final EnforcementAuditSink audit;
private final Map<UUID, EnumMap<DetectionSignal, State>> players = new HashMap<>();
EnforcementMonitor(EnforcementAuditSink audit) { this.audit = audit; }
EnforcementDecision observe(UUID playerId, DetectionSignal signal, long nowNanos,
EnforcementSettings settings, boolean safeAnchor, boolean recentDamage) {
return observe(playerId, signal, nowNanos, Long.MIN_VALUE, settings, safeAnchor, recentDamage);
}
EnforcementDecision observe(UUID playerId, DetectionSignal signal, long nowNanos, long episodeKey,
EnforcementSettings settings, boolean safeAnchor, boolean recentDamage) {
if (settings.mode() == EnforcementMode.OFF || !settings.eligibleSignals().contains(signal)) {
return EnforcementDecision.none();
}
var signals = players.computeIfAbsent(playerId, ignored -> new EnumMap<>(DetectionSignal.class));
var state = signals.computeIfAbsent(signal, ignored -> new State());
state.expire(nowNanos, settings.windowSeconds());
state.rearm(settings);
if (episodeKey != Long.MIN_VALUE && state.lastEpisodeKey == episodeKey) return EnforcementDecision.none();
state.lastEpisodeKey = episodeKey;
state.episodes.addLast(nowNanos);
int count = state.episodes.size();
EnforcementAction action = EnforcementAction.NONE;
String reason = "none";
if (settings.mode().kicks() && count >= settings.kickEpisodes() && !state.kicked) {
action = EnforcementAction.KICK;
state.kicked = true;
} else if (settings.mode().setsBack() && count >= settings.setbackEpisodes() && !state.setBack) {
if (recentDamage || !safeAnchor) {
action = EnforcementAction.SKIPPED;
reason = recentDamage ? "recent-damage" : "no-safe-anchor";
} else {
action = EnforcementAction.SETBACK;
}
state.setBack = true;
state.warned = true;
} else if (settings.mode().warns() && count >= settings.warningEpisodes() && !state.warned) {
action = EnforcementAction.WARNING;
state.warned = true;
}
if (action == EnforcementAction.NONE) return EnforcementDecision.none();
state.lastAction = action;
var decision = new EnforcementDecision(action, signal, count, settings.windowSeconds(),
action == EnforcementAction.SETBACK ? settings.holdSeconds() : 0, reason);
audit.offer(new EnforcementAudit(playerId, signal, action, count, settings.windowSeconds(),
decision.holdSeconds(), reason));
return decision;
}
List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos, EnforcementSettings settings) {
var signals = players.get(playerId);
if (signals == null) return List.of();
var snapshots = new ArrayList<EnforcementSnapshot>();
signals.forEach((signal, state) -> {
state.expire(nowNanos, settings.windowSeconds());
if (!state.episodes.isEmpty()) {
long expires = state.episodes.getFirst() + seconds(settings.windowSeconds());
snapshots.add(new EnforcementSnapshot(signal, state.episodes.size(),
Math.max(0, expires - nowNanos) / 1_000_000_000.0, state.lastAction));
}
});
snapshots.sort(Comparator.comparing(snapshot -> snapshot.signal().name()));
return List.copyOf(snapshots);
}
void reset(UUID playerId) { players.remove(playerId); }
void clear() { players.clear(); }
private static long seconds(int value) { return value * 1_000_000_000L; }
private static final class State {
private final ArrayDeque<Long> episodes = new ArrayDeque<>();
private boolean warned;
private boolean setBack;
private boolean kicked;
private EnforcementAction lastAction = EnforcementAction.NONE;
private long lastEpisodeKey = Long.MIN_VALUE;
private void expire(long nowNanos, int windowSeconds) {
long cutoff = nowNanos - seconds(windowSeconds);
while (!episodes.isEmpty() && episodes.getFirst() <= cutoff) episodes.removeFirst();
if (episodes.isEmpty()) lastAction = EnforcementAction.NONE;
}
private void rearm(EnforcementSettings settings) {
int count = episodes.size();
if (count < settings.warningEpisodes()) warned = false;
if (count < settings.setbackEpisodes()) setBack = false;
if (count < settings.kickEpisodes()) kicked = false;
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.purpursentinel;
import java.util.List;
import java.util.UUID;
interface EnforcementRuntime {
EnforcementRuntime NONE = new EnforcementRuntime() {
@Override public void observe(AlertDecision alert) { }
@Override public void reset(UUID playerId) { }
@Override public List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos) { return List.of(); }
@Override public double holdRemainingSeconds(UUID playerId, long nowNanos) { return 0; }
@Override public void policyChanged() { }
};
void observe(AlertDecision alert);
void reset(UUID playerId);
List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos);
double holdRemainingSeconds(UUID playerId, long nowNanos);
void policyChanged();
}
@@ -0,0 +1,115 @@
package games.dmg.purpursentinel;
import java.io.IOException;
import java.util.EnumSet;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public final class EnforcementService {
private static final String MODE = "enforcement-mode";
private final SettingsPersistence persistence;
private final AtomicReference<EnforcementSettings> current;
private EnforcementService(SettingsPersistence persistence, EnforcementSettings settings) {
this.persistence = persistence;
current = new AtomicReference<>(settings);
}
public static EnforcementService load(SettingsPersistence persistence, SettingsService ignored) throws IOException {
return new EnforcementService(persistence, parse(persistence.load().values()));
}
public EnforcementSettings current() { return current.get(); }
public synchronized String update(String key, String rawValue) throws IOException {
var latest = persistence.load();
var copy = new java.util.LinkedHashMap<>(latest.values());
copy.put(documentKey(key), parseValue(key, rawValue));
var document = new SettingsDocument(copy);
var next = parse(document.values());
persistence.save(document);
current.set(next);
return value(key);
}
public synchronized String updateSignal(DetectionSignal signal, boolean enabled) throws IOException {
if (enabled && !EnforcementSettings.enforceableSignals().contains(signal)) {
throw new IllegalArgumentException(signal + " is alert-only and cannot be enabled for enforcement.");
}
var latest = persistence.load();
var copy = new java.util.LinkedHashMap<>(latest.values());
copy.put(signalKey(signal), enabled);
var document = new SettingsDocument(copy);
var next = parse(document.values());
persistence.save(document);
current.set(next);
return enabled ? "on" : "off";
}
public String value(String key) {
var settings = current();
return switch (key) {
case "mode" -> settings.mode().value();
case "window-seconds" -> Integer.toString(settings.windowSeconds());
case "warning-episodes" -> Integer.toString(settings.warningEpisodes());
case "setback-episodes" -> Integer.toString(settings.setbackEpisodes());
case "kick-episodes" -> Integer.toString(settings.kickEpisodes());
case "hold-seconds" -> Integer.toString(settings.holdSeconds());
case "recent-damage-exclusion-seconds" -> Integer.toString(settings.recentDamageExclusionSeconds());
default -> throw new IllegalArgumentException("Unknown enforcement setting: " + key + '.');
};
}
private static EnforcementSettings parse(Map<String, Object> values) {
var defaults = EnforcementSettings.defaults();
String modeValue = values.containsKey(MODE) ? String.valueOf(values.get(MODE)) : defaults.mode().value();
if (values.get(MODE) instanceof Boolean bool) modeValue = bool ? "warn" : "off";
var mode = EnforcementMode.find(modeValue)
.orElseThrow(() -> new IllegalArgumentException("Invalid enforcement-mode."));
var signals = EnumSet.noneOf(DetectionSignal.class);
for (DetectionSignal signal : DetectionSignal.values()) {
boolean enabled = values.containsKey(signalKey(signal))
? bool(values.get(signalKey(signal)), signalKey(signal)) : defaults.eligibleSignals().contains(signal);
if (enabled && EnforcementSettings.enforceableSignals().contains(signal)) signals.add(signal);
}
return new EnforcementSettings(mode,
integer(values, "enforcement-window-seconds", defaults.windowSeconds()),
integer(values, "enforcement-warning-episodes", defaults.warningEpisodes()),
integer(values, "enforcement-setback-episodes", defaults.setbackEpisodes()),
integer(values, "enforcement-kick-episodes", defaults.kickEpisodes()),
integer(values, "enforcement-hold-seconds", defaults.holdSeconds()),
integer(values, "enforcement-recent-damage-exclusion-seconds", defaults.recentDamageExclusionSeconds()),
signals);
}
private static Object parseValue(String key, String rawValue) {
if (key.equals("mode")) return EnforcementMode.find(rawValue)
.orElseThrow(() -> new IllegalArgumentException("Invalid enforcement mode." )).value();
try {
if (!rawValue.matches("0|[1-9][0-9]*")) throw new NumberFormatException();
return Integer.parseInt(rawValue);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("Invalid value for enforcement " + key + '.', exception);
}
}
private static int integer(Map<String, Object> values, String key, int fallback) {
if (!values.containsKey(key)) return fallback;
Object value = values.get(key);
if (!(value instanceof Number number) || number.doubleValue() != Math.rint(number.doubleValue())) {
throw new IllegalArgumentException(key + " must be an integer.");
}
return Math.toIntExact(number.longValue());
}
private static boolean bool(Object value, String key) {
if (value instanceof Boolean bool) return bool;
throw new IllegalArgumentException(key + " must be true or false.");
}
private static String documentKey(String key) { return "enforcement-" + key; }
private static String signalKey(DetectionSignal signal) {
return "enforcement-signal-" + signal.name().toLowerCase(Locale.ROOT).replace('_', '-');
}
}
@@ -0,0 +1,50 @@
package games.dmg.purpursentinel;
import java.util.EnumSet;
import java.util.Set;
public record EnforcementSettings(
EnforcementMode mode,
int windowSeconds,
int warningEpisodes,
int setbackEpisodes,
int kickEpisodes,
int holdSeconds,
int recentDamageExclusionSeconds,
Set<DetectionSignal> eligibleSignals) {
public EnforcementSettings {
eligibleSignals = Set.copyOf(eligibleSignals);
range(windowSeconds, 60, 3600, "window-seconds");
range(warningEpisodes, 1, 20, "warning-episodes");
range(setbackEpisodes, 1, 40, "setback-episodes");
range(kickEpisodes, 1, 60, "kick-episodes");
range(holdSeconds, 1, 60, "hold-seconds");
range(recentDamageExclusionSeconds, 0, 60, "recent-damage-exclusion-seconds");
if (mode.setsBack() && warningEpisodes > setbackEpisodes) {
throw new IllegalArgumentException("warning-episodes cannot exceed setback-episodes.");
}
if (mode == EnforcementMode.SETBACK_KICK && setbackEpisodes > kickEpisodes) {
throw new IllegalArgumentException("setback-episodes cannot exceed kick-episodes.");
}
}
static EnforcementSettings defaults() {
return new EnforcementSettings(EnforcementMode.OFF, 600, 3, 5, 7, 10, 10, defaultSignals());
}
static Set<DetectionSignal> defaultSignals() { return enforceableSignals(); }
static Set<DetectionSignal> enforceableSignals() {
return EnumSet.of(DetectionSignal.ABSOLUTE_GROUND_SPEED, DetectionSignal.CONTEXTUAL_GROUND_SPEED,
DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, DetectionSignal.UNSUPPORTED_ASCENT,
DetectionSignal.UNSUPPORTED_HOVER, DetectionSignal.UNSUPPORTED_CRUISE,
DetectionSignal.ELYTRA_SOURCELESS_PROPULSION);
}
private static void range(int value, int minimum, int maximum, String key) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(key + " must be between " + minimum + " and " + maximum + '.');
}
}
}
@@ -0,0 +1,8 @@
package games.dmg.purpursentinel;
record EnforcementSnapshot(
DetectionSignal signal,
int episodeCount,
double windowRemainingSeconds,
EnforcementAction lastAction) {
}
@@ -0,0 +1,208 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
final class FlightDetector {
private final Map<UUID, State> players = new HashMap<>();
Optional<AlertDecision> observe(UUID playerId, FlightFrame frame, SentinelSettings settings) {
var state = players.computeIfAbsent(playerId, ignored -> new State());
state.expire(frame.observedAtNanos());
if (ineligible(frame)) {
state.resetSequence();
state.authorized = frame.authorized();
state.supported = frame.supportState() == SupportState.SUPPORTED;
state.currentExclusion = frame.exclusionReason() != ExclusionReason.NONE
? frame.exclusionReason() : frame.authorized() ? ExclusionReason.FLYING : ExclusionReason.ENVIRONMENT;
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.authorized = false;
state.supported = false;
state.currentExclusion = ExclusionReason.NONE;
state.eligibleTicks++;
double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
double vertical = frame.toY() - frame.fromY();
state.horizontalDistance += horizontal;
state.verticalDisplacement = frame.toY() - state.startY;
if (frame.flying()) state.unauthorizedFlightTicks++;
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, frame, 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.flight(playerId, state.lastEvidence));
}
void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason,
SentinelSettings settings) {
var state = players.computeIfAbsent(playerId, ignored -> new State());
state.expire(nowNanos);
state.resetSequence();
state.currentExclusion = reason;
state.graceThroughTick = Math.max(state.graceThroughTick,
currentTick + settings.exemptionGraceTicks());
}
Optional<FlightEvidence> inspect(UUID playerId, long nowNanos) {
var state = players.get(playerId);
if (state == null) return Optional.empty();
state.expire(nowNanos);
if (state.lastEvidence == null) return Optional.empty();
return Optional.of(state.evidence(nowNanos));
}
void cleanup(int currentTick, long nowNanos) {
players.entrySet().removeIf(entry -> {
var state = entry.getValue();
state.expire(nowNanos);
if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence();
return state.world == null && state.lastEvidence == null
&& state.cooldownUntilNanos <= nowNanos && currentTick > state.graceThroughTick;
});
}
void forget(UUID playerId) {
players.remove(playerId);
}
void clear() {
players.clear();
}
private static boolean ineligible(FlightFrame frame) {
return frame.authorized() || 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, FlightFrame frame, SentinelSettings settings) {
if (frame.flying()) return DetectionSignal.UNAUTHORIZED_FLIGHT_STATE;
if (state.verticalDisplacement >= settings.unsupportedAscentBlocks()) {
return DetectionSignal.UNSUPPORTED_ASCENT;
}
if (state.hoverTicks >= settings.unsupportedHoverTicks()) return DetectionSignal.UNSUPPORTED_HOVER;
return DetectionSignal.UNSUPPORTED_CRUISE;
}
private static boolean thresholdReached(State state, SentinelSettings settings) {
return switch (state.signal) {
case UNAUTHORIZED_FLIGHT_STATE -> state.unauthorizedFlightTicks >= settings.unsupportedFlightTicks();
case UNSUPPORTED_ASCENT -> state.ascentTicks >= 10;
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, UNSUPPORTED_BOAT_ASCENT,
UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE, ELYTRA_SOURCELESS_PROPULSION,
ELYTRA_IMPLAUSIBLE_WEAR -> 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 world;
private int lastTick = Integer.MIN_VALUE;
private long firstObservedAtNanos;
private long evidenceExpiresAtNanos;
private long cooldownUntilNanos;
private double startY;
private int eligibleTicks;
private int unauthorizedFlightTicks;
private int ascentTicks;
private int hoverTicks;
private int cruiseTicks;
private int graceThroughTick = Integer.MIN_VALUE;
private double horizontalDistance;
private double verticalDisplacement;
private DetectionSignal signal = DetectionSignal.UNSUPPORTED_CRUISE;
private boolean authorized;
private boolean supported;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
private FlightEvidence lastEvidence;
private long evidenceFirstObservedAtNanos;
private boolean connected(FlightFrame frame) {
return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick();
}
private void startSequence(FlightFrame 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 = 0;
unauthorizedFlightTicks = 0;
ascentTicks = 0;
hoverTicks = 0;
cruiseTicks = 0;
horizontalDistance = 0;
verticalDisplacement = 0;
signal = DetectionSignal.UNSUPPORTED_CRUISE;
}
private void expire(long nowNanos) {
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
}
private FlightEvidence evidence(long nowNanos) {
FlightEvidence source = lastEvidence;
int ticks = eligibleTicks;
int flyingTicks = unauthorizedFlightTicks;
int climbingTicks = ascentTicks;
int levelTicks = hoverTicks;
int travelTicks = cruiseTicks;
double horizontal = horizontalDistance;
double vertical = verticalDisplacement;
DetectionSignal currentSignal = signal;
long first = firstObservedAtNanos;
if (eligibleTicks == 0 && source != null) {
ticks = source.eligibleTicks();
flyingTicks = source.unauthorizedFlightTicks();
climbingTicks = source.ascentTicks();
levelTicks = source.hoverTicks();
travelTicks = source.cruiseTicks();
horizontal = source.horizontalDistance();
vertical = source.verticalDisplacement();
currentSignal = source.signal();
first = evidenceFirstObservedAtNanos;
}
return new FlightEvidence(currentSignal, authorized, supported, ticks,
flyingTicks, climbingTicks, levelTicks, travelTicks, 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,17 @@
package games.dmg.purpursentinel;
public record FlightEvidence(
DetectionSignal signal,
boolean authorized,
boolean supported,
int eligibleTicks,
int unauthorizedFlightTicks,
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 FlightFrame(
int tick,
long observedAtNanos,
UUID world,
double fromX,
double fromY,
double fromZ,
double toX,
double toY,
double toZ,
boolean authorized,
boolean flying,
SupportState supportState,
ExclusionReason exclusionReason) {
public static FlightFrame unsupported(int tick, long observedAtNanos, UUID world,
double fromX, double fromY, double fromZ, double toX, double toY, double toZ,
boolean authorized, boolean flying) {
return new FlightFrame(tick, observedAtNanos, world,
fromX, fromY, fromZ, toX, toY, toZ,
authorized, flying, SupportState.UNSUPPORTED, ExclusionReason.NONE);
}
}
@@ -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) {
}
@@ -6,5 +6,28 @@ public record InspectionSnapshot(
double peakSpeed,
double capturedLimit,
ExclusionReason currentExclusion,
double cooldownRemainingSeconds) {
double cooldownRemainingSeconds,
FlightEvidence flightEvidence,
GroundSpeedEvidence contextualEvidence,
BoatEvidence boatEvidence,
ElytraEvidence elytraEvidence) {
public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit,
ExclusionReason currentExclusion, double cooldownRemainingSeconds) {
this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit,
currentExclusion, cooldownRemainingSeconds, null, null, null, null);
}
public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit,
ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence) {
this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit,
currentExclusion, cooldownRemainingSeconds, flightEvidence, null, null, null);
}
public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit,
ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence,
GroundSpeedEvidence contextualEvidence) {
this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit,
currentExclusion, cooldownRemainingSeconds, flightEvidence, contextualEvidence, null, null);
}
}
@@ -21,27 +21,36 @@ public final class MovementDetector {
state.graceThroughTick = Math.max(state.graceThroughTick, sample.tick() + settings.exemptionGraceTicks());
return Optional.empty();
}
if (sample.tick() <= state.graceThroughTick) {
return Optional.empty();
}
if (sample.tick() <= state.graceThroughTick) return Optional.empty();
state.currentExclusion = ExclusionReason.NONE;
if (sample.horizontalDistance() <= settings.horizontalSpeedLimit()) {
return Optional.empty();
}
if (state.evidence.size() == MAX_EVIDENCE_PER_PLAYER) {
state.evidence.removeFirst();
}
state.evidence.addLast(new Breach(
sample.observedAtNanos(),
sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()),
sample.horizontalDistance(),
settings.horizontalSpeedLimit()));
if (state.evidence.size() < settings.breachesToAlert() || sample.observedAtNanos() < state.cooldownUntilNanos) {
boolean absolute = sample.horizontalDistance() > settings.horizontalSpeedLimit();
double expectedLimit = sample.groundContext() == null ? Double.NaN : sample.groundContext().expectedSpeed();
boolean contextual = Double.isFinite(expectedLimit)
&& sample.horizontalDistance() > expectedLimit * settings.contextualSpeedRatio()
&& sample.horizontalDistance() - expectedLimit > settings.contextualMinimumExcess();
if (!absolute && !contextual) return Optional.empty();
var breach = new Breach(sample.observedAtNanos(),
sample.observedAtNanos() + seconds(settings.evidenceWindowSeconds()), sample.horizontalDistance(), 0);
if (absolute) add(state.absoluteEvidence,
breach.withCapturedLimit(settings.horizontalSpeedLimit()));
if (contextual) add(state.contextualEvidence, breach.withCapturedLimit(expectedLimit));
boolean absoluteReady = absolute && state.absoluteEvidence.size() >= settings.breachesToAlert();
boolean contextualReady = contextual && state.contextualEvidence.size() >= settings.breachesToAlert();
if ((!absoluteReady && !contextualReady) || sample.observedAtNanos() < state.cooldownUntilNanos) {
return Optional.empty();
}
state.cooldownUntilNanos = sample.observedAtNanos() + seconds(settings.alertCooldownSeconds());
var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new AlertDecision(playerId, state.evidence.size(), peak.speed(), peak.capturedLimit()));
if (absoluteReady) {
var peak = state.absoluteEvidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(AlertDecision.absolute(playerId, state.absoluteEvidence.size(),
peak.speed(), peak.capturedLimit(), settings.evidenceWindowSeconds()));
}
var peak = state.contextualEvidence.stream().max(Comparator.comparingDouble(Breach::ratio)).orElseThrow();
return Optional.of(AlertDecision.contextual(playerId, state.contextualEvidence.size(), peak.speed(),
peak.capturedLimit(), settings.evidenceWindowSeconds()));
}
public void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason, SentinelSettings settings) {
@@ -53,65 +62,78 @@ public final class MovementDetector {
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
var state = players.get(playerId);
if (state == null) {
return Optional.empty();
}
if (state == null) return Optional.empty();
expire(state, nowNanos);
if (state.evidence.isEmpty()) {
return Optional.empty();
if (state.absoluteEvidence.isEmpty() && state.contextualEvidence.isEmpty()) return Optional.empty();
GroundSpeedEvidence contextual = snapshot(state.contextualEvidence, DetectionSignal.CONTEXTUAL_GROUND_SPEED,
state, nowNanos).orElse(null);
if (state.absoluteEvidence.isEmpty()) {
return Optional.of(new InspectionSnapshot(0, 0, 0, 0, state.currentExclusion,
cooldown(state, nowNanos), null, contextual));
}
var first = state.evidence.getFirst();
var peak = state.evidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new InspectionSnapshot(
state.evidence.size(),
Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0,
peak.speed(),
peak.capturedLimit(),
state.currentExclusion,
Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0));
var first = state.absoluteEvidence.getFirst();
var peak = state.absoluteEvidence.stream().max(Comparator.comparingDouble(Breach::speed)).orElseThrow();
return Optional.of(new InspectionSnapshot(state.absoluteEvidence.size(), age(first, nowNanos),
peak.speed(), peak.capturedLimit(), state.currentExclusion, cooldown(state, nowNanos),
null, contextual));
}
public void cleanup(int currentTick, long nowNanos) {
players.entrySet().removeIf(entry -> {
var state = entry.getValue();
expire(state, nowNanos);
return state.evidence.isEmpty() && state.cooldownUntilNanos <= nowNanos
&& currentTick > state.graceThroughTick;
return state.absoluteEvidence.isEmpty() && state.contextualEvidence.isEmpty()
&& state.cooldownUntilNanos <= nowNanos && currentTick > state.graceThroughTick;
});
}
public void forget(UUID playerId) {
players.remove(playerId);
}
public void clear() {
players.clear();
}
int trackedPlayerCount() {
return players.size();
}
public void forget(UUID playerId) { players.remove(playerId); }
public void clear() { players.clear(); }
int trackedPlayerCount() { return players.size(); }
int evidenceCount(UUID playerId) {
var state = players.get(playerId);
return state == null ? 0 : state.evidence.size();
return state == null ? 0 : state.absoluteEvidence.size() + state.contextualEvidence.size();
}
private static long seconds(int value) {
return value * 1_000_000_000L;
private static Optional<GroundSpeedEvidence> snapshot(ArrayDeque<Breach> evidence, DetectionSignal signal,
PlayerState state, long nowNanos) {
if (evidence.isEmpty()) return Optional.empty();
var peak = evidence.stream().max(Comparator.comparingDouble(Breach::ratio)).orElseThrow();
return Optional.of(new GroundSpeedEvidence(signal, evidence.size(), age(evidence.getFirst(), nowNanos),
peak.speed(), peak.capturedLimit(), peak.ratio(), state.currentExclusion,
cooldown(state, nowNanos)));
}
private static void add(ArrayDeque<Breach> evidence, Breach breach) {
if (evidence.size() == MAX_EVIDENCE_PER_PLAYER) evidence.removeFirst();
evidence.addLast(breach);
}
private static long seconds(int value) { return value * 1_000_000_000L; }
private static double age(Breach first, long nowNanos) {
return Math.max(0, nowNanos - first.observedAtNanos()) / 1_000_000_000.0;
}
private static double cooldown(PlayerState state, long nowNanos) {
return Math.max(0, state.cooldownUntilNanos - nowNanos) / 1_000_000_000.0;
}
private static void expire(PlayerState state, long nowNanos) {
while (!state.evidence.isEmpty() && state.evidence.getFirst().expiresAtNanos() <= nowNanos) {
state.evidence.removeFirst();
}
expire(state.absoluteEvidence, nowNanos);
expire(state.contextualEvidence, nowNanos);
}
private static void expire(ArrayDeque<Breach> evidence, long nowNanos) {
while (!evidence.isEmpty() && evidence.getFirst().expiresAtNanos() <= nowNanos) evidence.removeFirst();
}
private record Breach(long observedAtNanos, long expiresAtNanos, double speed, double capturedLimit) {
Breach withCapturedLimit(double value) {
return new Breach(observedAtNanos, expiresAtNanos, speed, value);
}
double ratio() { return speed / capturedLimit; }
}
private static final class PlayerState {
private final ArrayDeque<Breach> evidence = new ArrayDeque<>();
private final ArrayDeque<Breach> absoluteEvidence = new ArrayDeque<>();
private final ArrayDeque<Breach> contextualEvidence = new ArrayDeque<>();
private int graceThroughTick = Integer.MIN_VALUE;
private long cooldownUntilNanos;
private ExclusionReason currentExclusion = ExclusionReason.NONE;
@@ -24,6 +24,68 @@ final class MovementEnvironment {
private MovementEnvironment() {
}
static SupportState supportState(Location location) {
var world = location.getWorld();
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
return SupportState.UNKNOWN;
}
double minX = location.getX() - 0.3;
double maxX = location.getX() + 0.3;
double minZ = location.getZ() - 0.3;
double maxZ = location.getZ() + 0.3;
int y = (int) Math.floor(location.getY() - 0.01);
for (int x = (int) Math.floor(minX); x <= (int) Math.floor(maxX); x++) {
for (int z = (int) Math.floor(minZ); z <= (int) Math.floor(maxZ); z++) {
var block = world.getBlockAt(x, y, z);
if (block.getType() != Material.AIR && !block.isLiquid() && !block.isPassable()) {
return SupportState.SUPPORTED;
}
}
}
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 isSafeAnchor(Location location) {
var world = location.getWorld();
if (world == null || !finite(location.getX(), location.getY(), location.getZ())
|| location.getY() < world.getMinHeight() + 1 || location.getY() + 2 >= world.getMaxHeight()
|| !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
return false;
}
var footing = world.getBlockAt(location.getBlockX(), (int) Math.floor(location.getY() - 0.01),
location.getBlockZ());
if (unsafe(footing.getType()) || footing.isLiquid() || footing.isPassable()) return false;
for (int y = location.getBlockY(); y <= location.getBlockY() + 1; y++) {
var body = world.getBlockAt(location.getBlockX(), y, location.getBlockZ());
if (unsafe(body.getType()) || body.isLiquid() || !body.isPassable()
|| body.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged()) {
return false;
}
}
return true;
}
static boolean isOrdinary(Location location) {
var world = location.getWorld();
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
@@ -49,6 +111,16 @@ final class MovementEnvironment {
return true;
}
private static boolean unsafe(Material type) {
return ALTERING.contains(type) || type == Material.LAVA || type == Material.FIRE
|| type == Material.SOUL_FIRE || type == Material.CAMPFIRE || type == Material.SOUL_CAMPFIRE;
}
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
}
private static boolean isIce(Material type) {
return type == Material.ICE || type == Material.PACKED_ICE
|| type == Material.BLUE_ICE || type == Material.FROSTED_ICE;
@@ -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);
}
}
@@ -10,12 +10,28 @@ import java.util.UUID;
public final class MovementMonitor {
private static final double CHAIN_TOLERANCE = 1.0e-6;
private final MovementDetector detector;
private final FlightDetector flightDetector = new FlightDetector();
private final BoatDetector boatDetector = new BoatDetector();
private final ElytraDetector elytraDetector = new ElytraDetector();
private final Map<UUID, TickBucket> pending = new HashMap<>();
private final List<AlertDecision> flightAlerts = new ArrayList<>();
public MovementMonitor(MovementDetector detector) {
this.detector = detector;
}
public void record(UUID playerId, FlightFrame frame, SentinelSettings settings) {
flightDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add);
}
public void record(UUID playerId, ElytraFrame frame, SentinelSettings settings) {
elytraDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add);
}
public void record(BoatFrame frame, SentinelSettings settings) {
boatDetector.observe(frame, settings).ifPresent(flightAlerts::add);
}
public void record(UUID playerId, MovementFrame frame, SentinelSettings settings) {
var existing = pending.get(playerId);
if (existing != null && existing.tick != frame.tick()) {
@@ -31,7 +47,8 @@ public final class MovementMonitor {
}
public List<AlertDecision> finishBefore(int currentTick, long nowNanos) {
var alerts = new ArrayList<AlertDecision>();
var alerts = new ArrayList<AlertDecision>(flightAlerts);
flightAlerts.clear();
var iterator = pending.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
@@ -41,26 +58,58 @@ public final class MovementMonitor {
}
}
detector.cleanup(currentTick, nowNanos);
flightDetector.cleanup(currentTick, nowNanos);
boatDetector.cleanup(currentTick, nowNanos);
elytraDetector.cleanup(nowNanos);
return List.copyOf(alerts);
}
public void exempt(UUID playerId, int tick, long nowNanos, ExclusionReason reason, SentinelSettings settings) {
pending.remove(playerId);
detector.exempt(playerId, tick, nowNanos, reason, settings);
flightDetector.exempt(playerId, tick, nowNanos, reason, settings);
}
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
return detector.inspect(playerId, nowNanos);
var movement = detector.inspect(playerId, nowNanos);
var flight = flightDetector.inspect(playerId, nowNanos);
var boat = boatDetector.inspect(playerId, nowNanos);
var elytra = elytraDetector.inspect(playerId, nowNanos);
if (flight.isEmpty() && boat.isEmpty() && elytra.isEmpty()) return movement;
if (movement.isEmpty()) {
return Optional.of(new InspectionSnapshot(0, 0, 0, 0,
ExclusionReason.NONE, 0, flight.orElse(null), null, boat.orElse(null), elytra.orElse(null)));
}
var evidence = movement.get();
return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(),
evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(),
evidence.cooldownRemainingSeconds(), flight.orElse(null), evidence.contextualEvidence(),
boat.orElse(null), elytra.orElse(null)));
}
public void forget(UUID playerId) {
pending.remove(playerId);
detector.forget(playerId);
flightDetector.forget(playerId);
boatDetector.forget(playerId);
elytraDetector.forget(playerId);
}
public void clear() {
pending.clear();
detector.clear();
flightDetector.clear();
boatDetector.clear();
elytraDetector.clear();
flightAlerts.clear();
}
public void forgetElytra(UUID playerId) {
elytraDetector.forget(playerId);
}
public void releaseBoat(UUID boatId) {
boatDetector.release(boatId);
}
int trackedPlayerCount() {
@@ -74,7 +123,8 @@ public final class MovementMonitor {
!bucket.invalid,
bucket.reason == ExclusionReason.CANCELLED,
bucket.maximumDistance,
bucket.reason), bucket.settings);
bucket.reason,
bucket.groundContext), bucket.settings);
}
private static final class TickBucket {
@@ -84,6 +134,7 @@ public final class MovementMonitor {
private final double anchorX;
private final double anchorZ;
private final SentinelSettings settings;
private final GroundMovementContext groundContext;
private double lastX;
private double lastZ;
private double previousFromX;
@@ -102,6 +153,7 @@ public final class MovementMonitor {
anchorX = frame.fromX();
anchorZ = frame.fromZ();
this.settings = settings;
groundContext = frame.groundContext();
lastX = frame.fromX();
lastZ = frame.fromZ();
reason = ExclusionReason.NONE;
@@ -115,7 +167,8 @@ public final class MovementMonitor {
void add(MovementFrame frame) {
if (!world.equals(frame.toWorld()) || !world.equals(frame.fromWorld())
|| !finite(frame.fromX(), frame.fromZ(), frame.toX(), frame.toZ())
|| !connected(frame.fromX(), frame.fromZ())) {
|| !connected(frame.fromX(), frame.fromZ())
|| !java.util.Objects.equals(groundContext, frame.groundContext())) {
invalid = true;
reason = ExclusionReason.INCOMPLETE;
return;
@@ -6,5 +6,10 @@ public record MovementSample(
boolean complete,
boolean cancelled,
double horizontalDistance,
ExclusionReason exclusionReason) {
ExclusionReason exclusionReason,
GroundMovementContext groundContext) {
public MovementSample(int tick, long observedAtNanos, boolean complete, boolean cancelled,
double horizontalDistance, ExclusionReason exclusionReason) {
this(tick, observedAtNanos, complete, cancelled, horizontalDistance, exclusionReason, null);
}
}
@@ -2,6 +2,7 @@ package games.dmg.purpursentinel;
import java.io.IOException;
import java.nio.file.Files;
import java.time.Clock;
import java.util.Objects;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
@@ -10,6 +11,10 @@ import org.bukkit.scheduler.BukkitTask;
public final class PurpurSentinelPlugin extends JavaPlugin {
private MovementMonitor monitor;
private BukkitMovementListener listener;
private BukkitBoatObserver boatObserver;
private BukkitElytraObserver elytraObserver;
private BukkitEnforcement enforcementRuntime;
private CloudEventLog eventLog;
private BukkitTask tickTask;
@Override public void onEnable() {
@@ -17,20 +22,41 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
Files.createDirectories(getDataFolder().toPath());
var configPath = getDataFolder().toPath().resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(configPath);
var settings = SettingsService.load(new AtomicYamlSettingsStore(configPath));
var persistence = new AtomicYamlSettingsStore(configPath);
var settings = SettingsService.load(persistence);
var enforcement = EnforcementService.load(persistence, settings);
monitor = new MovementMonitor(new MovementDetector());
listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime);
eventLog = new CloudEventLog(getDataFolder().toPath().resolve("events"), Clock.systemUTC(),
message -> getLogger().warning(message));
enforcementRuntime = new BukkitEnforcement(getServer(), enforcement::current, System::nanoTime,
MovementEnvironment::isSafeAnchor, eventLog);
listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime,
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to),
MovementEnvironment::supportState, BukkitMovementListener::groundContext, enforcementRuntime);
boatObserver = new BukkitBoatObserver(getServer(), monitor, settings::current, System::nanoTime);
elytraObserver = new BukkitElytraObserver(getServer(), monitor, settings::current, System::nanoTime);
var command = Objects.requireNonNull(getCommand("sentinel"), "Missing sentinel command");
var handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime);
var handler = new SentinelCommand(getServer(), monitor, settings, enforcement, enforcementRuntime,
System::nanoTime);
command.setExecutor(handler);
command.setTabCompleter(handler);
getServer().getPluginManager().registerEvents(listener, this);
var alerts = new SentinelAlerts(getServer());
tickTask = getServer().getScheduler().runTaskTimer(this, () -> monitor
.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send), 1L, 1L);
getServer().getPluginManager().registerEvents(boatObserver, this);
getServer().getPluginManager().registerEvents(elytraObserver, this);
getServer().getPluginManager().registerEvents(enforcementRuntime, this);
var alerts = new SentinelAlerts(getServer(), eventLog, enforcementRuntime);
tickTask = getServer().getScheduler().runTaskTimer(this, () -> {
listener.sampleFlight();
boatObserver.sample();
elytraObserver.sample();
monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send);
}, 1L, 1L);
} catch (IOException | IllegalArgumentException exception) {
getLogger().severe("Sentinel configuration is invalid or unavailable; observation was not activated: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this);
} catch (RuntimeException exception) {
getLogger().severe("Sentinel could not activate observation.");
getServer().getPluginManager().disablePlugin(this);
}
}
@@ -40,12 +66,32 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
tickTask = null;
}
if (listener != null) {
listener.clear();
HandlerList.unregisterAll(listener);
listener = null;
}
if (boatObserver != null) {
boatObserver.clear();
HandlerList.unregisterAll(boatObserver);
boatObserver = null;
}
if (elytraObserver != null) {
elytraObserver.clear();
HandlerList.unregisterAll(elytraObserver);
elytraObserver = null;
}
if (monitor != null) {
monitor.clear();
monitor = null;
}
if (enforcementRuntime != null) {
enforcementRuntime.clear();
HandlerList.unregisterAll(enforcementRuntime);
enforcementRuntime = null;
}
if (eventLog != null) {
eventLog.close();
eventLog = null;
}
}
}
@@ -5,17 +5,57 @@ import org.bukkit.Server;
final class SentinelAlerts {
private final Server server;
private final AlertEventSink events;
private final EnforcementRuntime enforcement;
SentinelAlerts(Server server) {
this(server, AlertEventSink.NONE, EnforcementRuntime.NONE);
}
SentinelAlerts(Server server, AlertEventSink events) {
this(server, events, EnforcementRuntime.NONE);
}
SentinelAlerts(Server server, AlertEventSink events, EnforcementRuntime enforcement) {
this.server = server;
this.events = events;
this.enforcement = enforcement;
}
void send(AlertDecision alert) {
events.offer(alert);
enforcement.observe(alert);
var subject = server.getPlayer(alert.playerId());
if (subject == null || !subject.isOnline()) return;
String 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());
String message;
if (alert.signal() == DetectionSignal.ABSOLUTE_GROUND_SPEED) {
message = String.format(Locale.ROOT,
"[Sentinel] %s: ABSOLUTE_GROUND_SPEED; %d movement breaches in %ds window; peak %.2f blocks/tick vs captured limit %.2f; ratio %.2f. Use /sentinel inspect %s.",
subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(),
alert.capturedLimit(), alert.ratio(), subject.getName());
} else if (alert.signal() == DetectionSignal.CONTEXTUAL_GROUND_SPEED) {
message = String.format(Locale.ROOT,
"[Sentinel] %s: CONTEXTUAL_GROUND_SPEED; %d breaches in %ds window; observed %.2f blocks/tick vs expected %.2f; ratio %.2f. Use /sentinel inspect %s.",
subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(),
alert.capturedLimit(), alert.ratio(), subject.getName());
} else if (alert.signal() == DetectionSignal.ELYTRA_SOURCELESS_PROPULSION
|| alert.signal() == DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR) {
message = String.format(Locale.ROOT,
"[Sentinel] %s: %s; %d eligible ticks; ascent %.2f blocks; peak acceleration %.2f; observed boosts %d; wear opportunities %d; Unbreaking %.0f. Use /sentinel inspect %s.",
subject.getName(), alert.signal(), alert.eligibleTicks(), alert.verticalDisplacement(),
alert.peakSpeed(), alert.evidenceWindowSeconds(), alert.breachCount(), alert.capturedLimit(),
subject.getName());
} else if (alert.vehicleType() != null) {
message = String.format(Locale.ROOT,
"[Sentinel] %s: %s; boat %s; %d unsupported ticks; %.2f horizontal blocks; %.2f ascent blocks. Use /sentinel inspect %s.",
subject.getName(), alert.signal(), alert.vehicleType(), alert.eligibleTicks(),
alert.horizontalDistance(), alert.verticalDisplacement(), subject.getName());
} else {
message = String.format(Locale.ROOT,
"[Sentinel] %s: %s; %d unsupported ticks; %.2f horizontal blocks; %.2f vertical blocks. Use /sentinel inspect %s.",
subject.getName(), alert.signal(), alert.eligibleTicks(), alert.horizontalDistance(),
alert.verticalDisplacement(), subject.getName());
}
server.getOnlinePlayers().stream()
.filter(recipient -> recipient.hasPermission("sentinel.alerts"))
.forEach(recipient -> recipient.sendMessage(message));
@@ -19,12 +19,26 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
private final Server server;
private final MovementMonitor monitor;
private final SettingsService settings;
private final EnforcementService enforcement;
private final EnforcementRuntime enforcementRuntime;
private final LongSupplier nanoTime;
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings, LongSupplier nanoTime) {
this(server, monitor, settings, null, EnforcementRuntime.NONE, nanoTime);
}
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings,
EnforcementService enforcement, LongSupplier nanoTime) {
this(server, monitor, settings, enforcement, EnforcementRuntime.NONE, nanoTime);
}
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings,
EnforcementService enforcement, EnforcementRuntime enforcementRuntime, LongSupplier nanoTime) {
this.server = server;
this.monitor = monitor;
this.settings = settings;
this.enforcement = enforcement;
this.enforcementRuntime = enforcementRuntime;
this.nanoTime = nanoTime;
}
@@ -36,6 +50,7 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
}
if (args[0].equalsIgnoreCase("inspect")) return inspect(sender, args);
if (args[0].equalsIgnoreCase("config")) return configure(sender, args);
if (args[0].equalsIgnoreCase("enforcement")) return enforce(sender, args);
sender.sendMessage("Unsupported Sentinel route: " + args[0] + '.');
return true;
}
@@ -45,9 +60,23 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
if (args.length == 1) {
var routes = new ArrayList<String>();
if (sender.hasPermission("sentinel.config")) routes.add("config");
if (sender.hasPermission("sentinel.enforcement")) routes.add("enforcement");
if (sender.hasPermission("sentinel.inspect")) routes.add("inspect");
return filter(routes, args[0]);
}
if (args[0].equalsIgnoreCase("enforcement") && sender.hasPermission("sentinel.enforcement")) {
if (args.length == 2) return filter(List.of("hold-seconds", "kick-episodes", "mode",
"recent-damage-exclusion-seconds", "reset", "setback-episodes", "signal", "status",
"warning-episodes", "window-seconds"), args[1]);
if (args.length == 3 && args[1].equalsIgnoreCase("mode")) {
return filter(java.util.Arrays.stream(EnforcementMode.values()).map(EnforcementMode::value).toList(), args[2]);
}
if (args.length == 3 && args[1].equalsIgnoreCase("signal")) {
return filter(java.util.Arrays.stream(DetectionSignal.values())
.map(signal -> signal.name().toLowerCase(Locale.ROOT).replace('_', '-')).toList(), args[2]);
}
if (args.length == 4 && args[1].equalsIgnoreCase("signal")) return filter(List.of("off", "on"), args[3]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("inspect") && sender.hasPermission("sentinel.inspect")) {
return filter(server.getOnlinePlayers().stream().map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER).toList(), args[1]);
@@ -87,16 +116,65 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
}
return true;
}
var snapshot = monitor.inspect(player.get().getUniqueId(), nanoTime.getAsLong());
long now = nanoTime.getAsLong();
var snapshot = monitor.inspect(player.get().getUniqueId(), now);
var enforcementEvidence = enforcementRuntime.inspect(player.get().getUniqueId(), now);
double hold = enforcementRuntime.holdRemainingSeconds(player.get().getUniqueId(), now);
if (!enforcementEvidence.isEmpty() || hold > 0) {
String counts = enforcementEvidence.stream().map(item -> item.signal() + "=" + item.episodeCount())
.collect(java.util.stream.Collectors.joining(","));
sender.sendMessage(String.format(Locale.ROOT, "%s enforcement: mode %s; episodes %s; hold %.1fs; last action %s.",
player.get().getName(), enforcement == null ? "off" : enforcement.current().mode().value(), counts,
hold, enforcementEvidence.isEmpty() ? EnforcementAction.NONE : enforcementEvidence.getLast().lastAction()));
}
if (snapshot.isEmpty()) {
sender.sendMessage("No current evidence for " + player.get().getName() + "; none was recorded or it expired.");
if (enforcementEvidence.isEmpty() && hold <= 0) {
sender.sendMessage("No current evidence for " + player.get().getName() + "; none was recorded or it expired.");
}
return true;
}
var evidence = snapshot.get();
sender.sendMessage(String.format(Locale.ROOT,
"%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.",
player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(),
evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
if (evidence.elytraEvidence() != null) {
var elytra = evidence.elytraEvidence();
sender.sendMessage(String.format(Locale.ROOT,
"%s: %s; %d eligible ticks; %d sourceless ticks; ascent %.2f blocks; peak acceleration %.2f; observed boosts %d; wear opportunities %d; Unbreaking %d; age %.1fs; exclusion %s; cooldown %.1fs.",
player.get().getName(), elytra.signal(), elytra.eligibleTicks(), elytra.sourcelessTicks(),
elytra.ascent(), elytra.peakAcceleration(), elytra.observedBoosts(),
elytra.wearOpportunities(), elytra.unbreakingLevel(), elytra.evidenceAgeSeconds(),
elytra.currentExclusion().description(), elytra.cooldownRemainingSeconds()));
} else if (evidence.boatEvidence() != null) {
var boat = evidence.boatEvidence();
sender.sendMessage(String.format(Locale.ROOT,
"%s: %s; boat %s; supported %s; %d eligible ticks; signal counts ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.",
player.get().getName(), boat.signal(), boat.vehicleType(),
yesNo(boat.supportState() == SupportState.SUPPORTED), boat.eligibleTicks(),
boat.ascentTicks(), boat.hoverTicks(), boat.cruiseTicks(), boat.horizontalDistance(),
boat.verticalDisplacement(), boat.evidenceAgeSeconds(), boat.currentExclusion().description(),
boat.cooldownRemainingSeconds()));
} else if (evidence.flightEvidence() != null) {
var flight = evidence.flightEvidence();
sender.sendMessage(String.format(Locale.ROOT,
"%s: %s; authorized %s; supported %s; %d eligible ticks; signal counts flying %d, ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.",
player.get().getName(), flight.signal(), yesNo(flight.authorized()), yesNo(flight.supported()),
flight.eligibleTicks(), flight.unauthorizedFlightTicks(), flight.ascentTicks(), flight.hoverTicks(),
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; 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.peakSpeed() / evidence.capturedLimit(),
evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
}
return true;
}
@@ -129,6 +207,78 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
return true;
}
private boolean enforce(CommandSender sender, String[] args) {
if (!sender.hasPermission("sentinel.enforcement")) {
sender.sendMessage("You do not have permission to configure Sentinel enforcement.");
return true;
}
if (enforcement == null || args.length < 2) {
sender.sendMessage("Usage: /sentinel enforcement <status|mode|setting|signal|reset> ...");
return true;
}
if (args[1].equalsIgnoreCase("status") && args.length == 2) {
var current = enforcement.current();
sender.sendMessage(String.format(Locale.ROOT,
"Sentinel enforcement: mode %s; window %ds; warn %d; setback %d; kick %d; hold %ds; recent-damage exclusion %ds.",
current.mode().value(), current.windowSeconds(), current.warningEpisodes(),
current.setbackEpisodes(), current.kickEpisodes(), current.holdSeconds(),
current.recentDamageExclusionSeconds()));
return true;
}
if (args[1].equalsIgnoreCase("signal") && args.length == 4) {
var signal = java.util.Arrays.stream(DetectionSignal.values())
.filter(value -> value.name().toLowerCase(Locale.ROOT).replace('_', '-').equals(args[2].toLowerCase(Locale.ROOT)))
.findFirst();
if (signal.isEmpty() || !args[3].equalsIgnoreCase("on") && !args[3].equalsIgnoreCase("off")) {
sender.sendMessage("Invalid enforcement signal or state.");
return true;
}
try {
String value = enforcement.updateSignal(signal.get(), args[3].equalsIgnoreCase("on"));
enforcementRuntime.policyChanged();
sender.sendMessage("enforcement signal " + args[2].toLowerCase(Locale.ROOT) + " updated to " + value + '.');
} catch (IOException exception) {
sender.sendMessage("Could not save Sentinel enforcement; the prior value remains active.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
}
return true;
}
if (args[1].equalsIgnoreCase("reset")) {
if (args.length != 3) {
sender.sendMessage("Usage: /sentinel enforcement reset <player>");
return true;
}
var player = server.getOnlinePlayers().stream()
.filter(candidate -> candidate.getName().equalsIgnoreCase(args[2])).findFirst();
if (player.isEmpty()) {
sender.sendMessage("Unknown or offline player: " + args[2] + '.');
return true;
}
enforcementRuntime.reset(player.get().getUniqueId());
sender.sendMessage("Sentinel enforcement state reset for " + player.get().getName() + '.');
return true;
}
if (args.length != 3) {
sender.sendMessage("Usage: /sentinel enforcement <setting> <value>");
return true;
}
try {
String value = enforcement.update(args[1].toLowerCase(Locale.ROOT), args[2]);
enforcementRuntime.policyChanged();
sender.sendMessage("enforcement " + args[1].toLowerCase(Locale.ROOT) + " updated to " + value + '.');
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
sender.sendMessage("Could not save Sentinel enforcement; the prior value remains active.");
}
return true;
}
private static String yesNo(boolean value) {
return value ? "yes" : "no";
}
private static List<String> filter(List<String> options, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return options.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized))
@@ -6,7 +6,35 @@ public record SentinelSettings(
int evidenceWindowSeconds,
int alertCooldownSeconds,
double minimumTps,
int exemptionGraceTicks) {
int exemptionGraceTicks,
int unsupportedFlightTicks,
int unsupportedHoverTicks,
double unsupportedAscentBlocks,
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, 1.35, 0.10,
40, 30, 2.0, 12.0);
}
public SentinelSettings {
requireFiniteRange(horizontalSpeedLimit, 0.40, 5.00, "horizontal-speed-limit");
requireRange(breachesToAlert, 3, 40, "breaches-to-alert");
@@ -14,10 +42,21 @@ public record SentinelSettings(
requireRange(alertCooldownSeconds, 5, 3600, "alert-cooldown-seconds");
requireFiniteRange(minimumTps, 10.0, 20.0, "minimum-tps");
requireRange(exemptionGraceTicks, 1, 200, "exemption-grace-ticks");
requireRange(unsupportedFlightTicks, 10, 200, "unsupported-flight-ticks");
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);
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) {
@@ -10,7 +10,17 @@ public enum SettingKey {
EVIDENCE_WINDOW_SECONDS("evidence-window-seconds", "2", "60", true),
ALERT_COOLDOWN_SECONDS("alert-cooldown-seconds", "5", "3600", true),
MINIMUM_TPS("minimum-tps", "10.0", "20.0", false),
EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true);
EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true),
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),
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;
@@ -43,7 +43,17 @@ public final class SettingsService {
integer(values, SettingKey.EVIDENCE_WINDOW_SECONDS),
integer(values, SettingKey.ALERT_COOLDOWN_SECONDS),
decimal(values, SettingKey.MINIMUM_TPS),
integer(values, SettingKey.EXEMPTION_GRACE_TICKS));
integer(values, SettingKey.EXEMPTION_GRACE_TICKS),
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.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) {
@@ -60,6 +70,14 @@ public final class SettingsService {
}
}
private static int optionalInteger(Map<String, Object> values, SettingKey key, int defaultValue) {
return values.containsKey(key.key()) ? integer(values, key) : defaultValue;
}
private static double optionalDecimal(Map<String, Object> values, SettingKey key, double defaultValue) {
return values.containsKey(key.key()) ? decimal(values, key) : defaultValue;
}
private static int integer(Map<String, Object> values, SettingKey key) {
Object value = required(values, key);
if (!(value instanceof Number number)) throw new IllegalArgumentException(key.key() + " must be an integer.");
@@ -92,6 +110,16 @@ public final class SettingsService {
case ALERT_COOLDOWN_SECONDS -> Integer.toString(settings.alertCooldownSeconds());
case MINIMUM_TPS -> decimal(settings.minimumTps(), 1);
case EXEMPTION_GRACE_TICKS -> Integer.toString(settings.exemptionGraceTicks());
case UNSUPPORTED_FLIGHT_TICKS -> Integer.toString(settings.unsupportedFlightTicks());
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);
};
}
@@ -0,0 +1,7 @@
package games.dmg.purpursentinel;
public enum SupportState {
SUPPORTED,
UNSUPPORTED,
UNKNOWN
}
+28
View File
@@ -4,3 +4,31 @@ evidence-window-seconds: 10
alert-cooldown-seconds: 60
minimum-tps: 18.0
exemption-grace-ticks: 20
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
enforcement-mode: off
enforcement-window-seconds: 600
enforcement-warning-episodes: 3
enforcement-setback-episodes: 5
enforcement-kick-episodes: 7
enforcement-hold-seconds: 10
enforcement-recent-damage-exclusion-seconds: 10
enforcement-signal-absolute-ground-speed: true
enforcement-signal-contextual-ground-speed: true
enforcement-signal-unauthorized-flight-state: true
enforcement-signal-unsupported-ascent: true
enforcement-signal-unsupported-hover: true
enforcement-signal-unsupported-cruise: true
enforcement-signal-elytra-sourceless-propulsion: true
enforcement-signal-unsupported-boat-ascent: false
enforcement-signal-unsupported-boat-hover: false
enforcement-signal-unsupported-boat-cruise: false
enforcement-signal-elytra-implausible-wear: false
+4 -1
View File
@@ -6,7 +6,7 @@ description: Passive and explainable movement-pattern observation for operators.
commands:
sentinel:
description: Inspect movement evidence or configure Sentinel.
usage: /sentinel <inspect|config> ...
usage: /sentinel <inspect|config|enforcement> ...
permissions:
sentinel.alerts:
description: Receive passive movement evidence alerts.
@@ -17,3 +17,6 @@ permissions:
sentinel.config:
description: Read and update Sentinel configuration.
default: op
sentinel.enforcement:
description: Read, update, and reset optional Sentinel enforcement.
default: op
@@ -7,6 +7,7 @@ import io.papermc.paper.plugin.manager.NativeEvents;
import java.io.File;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@@ -15,6 +16,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 +46,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);
@@ -59,7 +63,7 @@ class PluginLifecycleTest {
var description = new PluginDescriptionFile(stream);
assertEquals("PurpurSentinel", description.getName());
assertEquals("26.3", description.getAPIVersion());
assertEquals(3, description.getPermissions().size());
assertEquals(4, description.getPermissions().size());
when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description);
}
@@ -90,8 +94,15 @@ 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);
when(operator.hasPermission("sentinel.config")).thenReturn(true);
when(operator.hasPermission("sentinel.enforcement")).thenReturn(true);
when(server.getPlayer(subject.getUniqueId())).thenReturn(subject);
doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers();
@@ -103,7 +114,12 @@ class PluginLifecycleTest {
assertNotNull(tick.get());
assertInstanceOf(SentinelCommand.class, command.getExecutor());
assertSame(command.getExecutor(), command.getTabCompleter());
assertTrue(Files.readString(directory.toPath().resolve("config.yml")).contains("breaches-to-alert: 6"));
String config = Files.readString(directory.toPath().resolve("config.yml"));
assertTrue(config.contains("breaches-to-alert: 6"));
assertTrue(config.contains("unsupported-boat-ticks: 40"));
verify(manager).registerEvents(isA(BukkitBoatObserver.class), eq(plugin));
verify(manager).registerEvents(isA(BukkitElytraObserver.class), eq(plugin));
verify(manager).registerEvents(isA(BukkitEnforcement.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)));
@@ -112,14 +128,27 @@ 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"}));
assertEquals(List.of("enforcement"), command.tabComplete(operator, "sentinel", new String[]{"enf"}));
assertTrue(command.execute(operator, "sentinel", new String[]{"enforcement", "status"}));
verify(operator).sendMessage(contains("mode off"));
Field monitorField = PurpurSentinelPlugin.class.getDeclaredField("monitor");
monitorField.setAccessible(true);
var monitor = (MovementMonitor) monitorField.get(plugin);
assertEquals(1, monitor.trackedPlayerCount());
plugin.onDisable();
Path eventFile = directory.toPath().resolve("events/" + java.time.LocalDate.now(java.time.ZoneOffset.UTC) + ".jsonl");
assertTrue(Files.isRegularFile(eventFile));
var eventLines = Files.readAllLines(eventFile);
assertEquals(1, eventLines.size());
assertTrue(eventLines.getFirst().contains("\"specversion\":\"1.0\""));
assertTrue(eventLines.getFirst().contains("\"subject\":\"" + subject.getUniqueId() + "\""));
assertFalse(eventLines.getFirst().contains("Alex"));
verify(task).cancel();
assertEquals(0, monitor.trackedPlayerCount());
assertEquals(0, PlayerMoveEvent.getHandlerList().getRegisteredListeners().length);
@@ -129,6 +158,30 @@ class PluginLifecycleTest {
}
}
@Test void nativeAttributesAndPotionAmplifiersFeedTheContextEnvelope() {
var player = mock(Player.class);
var movement = mock(AttributeInstance.class);
var sneaking = mock(AttributeInstance.class);
when(movement.getValue()).thenReturn(0.15);
when(sneaking.getValue()).thenReturn(0.4);
when(player.getAttribute(Attribute.MOVEMENT_SPEED)).thenReturn(movement);
when(player.getAttribute(Attribute.SNEAKING_SPEED)).thenReturn(sneaking);
when(player.isSprinting()).thenReturn(true);
when(player.isOnGround()).thenReturn(false);
when(player.getActivePotionEffects()).thenReturn(java.util.Set.of(
new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.SPEED, 100, 2)));
var context = BukkitMovementListener.groundContext(player);
assertNotNull(context);
assertEquals(0.15, context.movementSpeedAttribute(), 1e-9);
assertEquals(0.4, context.sneakingMultiplier(), 1e-9);
assertTrue(context.sprinting());
assertFalse(context.grounded());
assertEquals(2, context.speedAmplifier());
assertEquals(-1, context.slownessAmplifier());
}
@Test void invalidStartupConfigurationRegistersNothing() throws Exception {
Files.writeString(directory.toPath().resolve("config.yml"), "minimum-tps: bad\n");
var server = mock(Server.class);
@@ -0,0 +1,102 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class BoatMonitorTest {
private static final UUID WORLD = UUID.randomUUID();
private static final UUID BOAT = UUID.randomUUID();
private static final UUID PLAYER = UUID.randomUUID();
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
@Test void sustainedHoverAlertsCurrentControllerAndExposesBoundedEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
for (int tick = 1; tick <= 30; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), SETTINGS);
}
var alert = monitor.finishBefore(31, nanos(31)).getFirst();
assertEquals(PLAYER, alert.playerId());
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_HOVER, alert.signal());
assertEquals("BAMBOO_RAFT", alert.vehicleType());
assertEquals(30, alert.eligibleTicks());
var evidence = monitor.inspect(PLAYER, nanos(31)).orElseThrow().boatEvidence();
assertEquals(BOAT, evidence.vehicleId());
assertEquals(SupportState.UNSUPPORTED, evidence.supportState());
assertEquals(30, evidence.hoverTicks());
}
@Test void ascentAndLevelTravelHaveIndependentPersistenceThresholds() {
var ascent = new MovementMonitor(new MovementDetector());
double y = 80;
for (int tick = 1; tick <= 40; tick++) {
ascent.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 0, y + 0.05, 0), SETTINGS);
y += 0.05;
}
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_ASCENT,
ascent.finishBefore(41, nanos(41)).getFirst().signal());
var cruise = new MovementMonitor(new MovementDetector());
double x = 0;
for (int tick = 1; tick <= 40; tick++) {
cruise.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"CHERRY_CHEST_BOAT", x, 80, 0, x + 0.3, 80, 0), SETTINGS);
x += 0.3;
}
var alert = cruise.finishBefore(41, nanos(41)).getFirst();
assertEquals(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, alert.signal());
assertEquals(12.0, alert.horizontalDistance(), 1e-9);
}
@Test void ascentStillRequiresPersistenceEvenWhenDisplacementThresholdIsReachedEarly() {
var monitor = new MovementMonitor(new MovementDetector());
double y = 80;
for (int tick = 1; tick <= 10; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 0, y + 0.25, 0), SETTINGS);
y += 0.25;
}
assertTrue(monitor.finishBefore(11, nanos(11)).isEmpty());
}
@Test void ordinaryFallSupportUnknownGeometryAndOneSpikeNeverAlert() {
var monitor = new MovementMonitor(new MovementDetector());
double y = 90;
for (int tick = 1; tick <= 50; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 0, y - 0.2, 0), SETTINGS);
y -= 0.2;
}
monitor.record(new BoatFrame(51, nanos(51), BOAT, PLAYER, WORLD, "OAK_BOAT",
0, y, 0, 0, y, 0, SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS);
monitor.record(BoatFrame.unsupported(72, nanos(72), BOAT, PLAYER, WORLD,
"OAK_BOAT", 0, y, 0, 2, y, 0), SETTINGS);
assertTrue(monitor.finishBefore(73, nanos(73)).isEmpty());
assertTrue(monitor.inspect(PLAYER, nanos(73)).isEmpty());
}
@Test void controllerChangesRemovalAndCleanupNeverAttributeFormerPassenger() {
var monitor = new MovementMonitor(new MovementDetector());
var replacement = UUID.randomUUID();
for (int tick = 1; tick <= 29; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, PLAYER, WORLD,
"SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS);
}
monitor.releaseBoat(BOAT);
for (int tick = 50; tick <= 79; tick++) {
monitor.record(BoatFrame.unsupported(tick, nanos(tick), BOAT, replacement, WORLD,
"SPRUCE_BOAT", 0, 80, 0, 0, 80, 0), SETTINGS);
}
var alert = monitor.finishBefore(80, nanos(80)).getFirst();
assertEquals(replacement, alert.playerId());
assertTrue(monitor.inspect(PLAYER, nanos(80)).isEmpty());
monitor.clear();
assertTrue(monitor.inspect(replacement, nanos(80)).isEmpty());
}
private static long nanos(int tick) { return tick * 50_000_000L; }
}
@@ -0,0 +1,48 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.stream.Stream;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
class BoatSupportTest {
@ParameterizedTest(name = "{0}") @MethodSource("supportMaterials")
void waterIceAndCollidableTerrainSupportBoats(Material material) {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var block = mock(Block.class);
when(block.getType()).thenReturn(material);
when(block.isLiquid()).thenReturn(material == Material.WATER);
when(block.isPassable()).thenReturn(material == Material.WATER);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
assertEquals(SupportState.SUPPORTED, MovementEnvironment.boatSupportState(new Location(world, 0.5, 64, 0.5)));
}
@org.junit.jupiter.api.Test void pistonGeometryIsUnknownRatherThanEvidenceOrSupport() {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
var block = mock(Block.class);
when(block.getType()).thenReturn(Material.MOVING_PISTON);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0)));
}
@org.junit.jupiter.api.Test void unloadedChunksRemainUnknownWithoutBlockReads() {
var world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(false);
assertEquals(SupportState.UNKNOWN, MovementEnvironment.boatSupportState(new Location(world, 0, 64, 0)));
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
}
private static Stream<Material> supportMaterials() {
return Stream.of(Material.WATER, Material.ICE, Material.PACKED_ICE, Material.BLUE_ICE,
Material.OAK_PLANKS, Material.STONE);
}
}
@@ -0,0 +1,97 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Boat;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
class BukkitBoatObserverTest {
@Test void everyPurpurBoatAndRaftTypeIsRecognized() {
var names = java.util.Arrays.stream(EntityType.values()).map(Enum::name)
.filter(name -> name.endsWith("_BOAT") || name.endsWith("_CHEST_BOAT")
|| name.endsWith("_RAFT") || name.endsWith("_CHEST_RAFT"))
.toList();
assertFalse(names.isEmpty());
names.forEach(name -> assertTrue(BukkitBoatObserver.isBoatFamily(EntityType.valueOf(name)), name));
}
@Test void samplesServerBoatTrajectoryOncePerTickAndAttributesFirstPlayerPassenger() {
var server = mock(Server.class);
when(server.getCurrentTick()).thenReturn(1, 2);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
var boat = mock(Boat.class);
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getType()).thenReturn(EntityType.BAMBOO_RAFT);
when(boat.isValid()).thenReturn(true);
when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 0.2, 80, 0));
var controller = mock(Player.class);
when(controller.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getPassengers()).thenReturn(List.of(controller));
when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat));
when(server.getWorlds()).thenReturn(List.of(world));
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
new AtomicLong(50_000_000L)::get, ignored -> SupportState.UNSUPPORTED);
observer.sample();
observer.sample();
assertEquals(1, observer.trackedBoatCount());
assertTrue(monitor.finishBefore(3, 150_000_000L).isEmpty());
verify(boat, never()).setVelocity(any());
verify(boat, never()).eject();
verify(boat, never()).remove();
}
@Test void lifecycleEventsReleaseBoatStateWithoutMutatingVehicle() {
var server = mock(Server.class);
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
System::nanoTime, ignored -> SupportState.UNSUPPORTED);
var boat = mock(Boat.class);
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
observer.onCollision(new org.bukkit.event.vehicle.VehicleBlockCollisionEvent(boat,
mock(org.bukkit.block.Block.class)));
observer.onTeleport(new org.bukkit.event.entity.EntityTeleportEvent(boat,
mock(Location.class), mock(Location.class)));
verify(boat, never()).eject();
verify(boat, never()).remove();
verify(boat, never()).teleport(any(Location.class));
}
@Test void controllerLossAndInvalidBoatReleaseEvidence() {
var server = mock(Server.class);
when(server.getCurrentTick()).thenReturn(1, 2);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
var boat = mock(Boat.class);
when(boat.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getType()).thenReturn(EntityType.OAK_BOAT);
when(boat.isValid()).thenReturn(true, false);
when(boat.getLocation()).thenReturn(new Location(world, 0, 80, 0));
var controller = mock(Player.class);
when(controller.getUniqueId()).thenReturn(UUID.randomUUID());
when(boat.getPassengers()).thenReturn(List.of(controller));
when(world.getEntitiesByClass(Boat.class)).thenReturn(List.of(boat));
when(server.getWorlds()).thenReturn(List.of(world));
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitBoatObserver(server, monitor, SentinelSettings::defaults,
System::nanoTime, ignored -> SupportState.UNSUPPORTED);
observer.sample();
observer.sample();
assertEquals(0, observer.trackedBoatCount());
assertTrue(monitor.inspect(controller.getUniqueId(), System.nanoTime()).isEmpty());
}
}
@@ -0,0 +1,72 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
class BukkitElytraObserverTest {
@Test void samplesEquippedGlidingElytraAndCorrelatesObservedRocketUse() {
var server = mock(Server.class);
var tick = new AtomicInteger();
when(server.getCurrentTick()).thenAnswer(call -> tick.incrementAndGet());
var world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.isGliding()).thenReturn(true);
when(player.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 1, 80.2, 0));
var inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
var elytra = mock(ItemStack.class);
when(elytra.getType()).thenReturn(Material.ELYTRA);
var meta = mock(ItemMeta.class);
when(elytra.getItemMeta()).thenReturn(meta);
when(inventory.getChestplate()).thenReturn(elytra);
doReturn(List.of(player)).when(server).getOnlinePlayers();
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitElytraObserver(server, monitor, SentinelSettings::defaults, System::nanoTime,
ignored -> new BukkitElytraObserver.ItemContext(0, false));
var rocket = mock(ItemStack.class);
when(rocket.getType()).thenReturn(Material.FIREWORK_ROCKET);
var event = new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, rocket, null, null);
observer.onInteract(event);
observer.sample();
observer.sample();
assertEquals(1, observer.trackedPlayerCount());
assertTrue(monitor.finishBefore(3, System.nanoTime()).isEmpty());
verify(player, never()).setVelocity(any());
verify(player, never()).setGliding(anyBoolean());
verify(rocket, never()).setAmount(anyInt());
}
@Test void glideInterruptionAndArmorSwapClearObserverAndDetectorState() {
var server = mock(Server.class);
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
var monitor = new MovementMonitor(new MovementDetector());
var observer = new BukkitElytraObserver(server, monitor, SentinelSettings::defaults, System::nanoTime,
ignored -> new BukkitElytraObserver.ItemContext(0, false));
observer.onToggleGlide(new org.bukkit.event.entity.EntityToggleGlideEvent(player, false));
observer.onArmorChange(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(player,
com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST,
mock(ItemStack.class), mock(ItemStack.class)));
assertEquals(0, observer.trackedPlayerCount());
assertTrue(monitor.inspect(player.getUniqueId(), System.nanoTime()).isEmpty());
}
}
@@ -0,0 +1,91 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerMoveEvent;
import org.junit.jupiter.api.Test;
class BukkitEnforcementTest {
private static final DetectionSignal SIGNAL = DetectionSignal.ABSOLUTE_GROUND_SPEED;
@Test
void emittedEpisodesWarnSetbackHoldAndKickThroughFixedServerApis() {
var fixture = fixture(EnforcementMode.SETBACK_KICK);
fixture.listener.captureSafeAnchor(fixture.player, fixture.anchor);
for (int episode = 1; episode <= 7; episode++) {
when(fixture.server.getCurrentTick()).thenReturn(episode);
fixture.listener.observe(alert(fixture.id));
}
verify(fixture.player).sendMessage(argThat((net.kyori.adventure.text.Component component) -> component.toString().contains("repeated movement")));
verify(fixture.player).teleport(fixture.anchor);
verify(fixture.player).kick(argThat(component -> component.toString().contains("repeated movement")));
assertTrue(fixture.listener.holdRemainingSeconds(fixture.id, fixture.clock.get()) > 0);
var move = new PlayerMoveEvent(fixture.player, fixture.anchor,
new Location(fixture.world, 2, 64, 0, 90, 15));
fixture.listener.onMove(move);
assertEquals(fixture.anchor.getX(), move.getTo().getX());
assertEquals(90, move.getTo().getYaw());
assertEquals(15, move.getTo().getPitch());
}
@Test
void recentDamageSkipsSetbackAndDamageEndsAnActiveHoldWithoutAnotherTeleport() {
var fixture = fixture(EnforcementMode.SETBACK);
fixture.listener.captureSafeAnchor(fixture.player, fixture.anchor);
fixture.listener.recordDamage(fixture.player);
for (int episode = 1; episode <= 5; episode++) {
when(fixture.server.getCurrentTick()).thenReturn(episode);
fixture.listener.observe(alert(fixture.id));
}
verify(fixture.player, never()).teleport(any(Location.class));
fixture.clock.addAndGet(11_000_000_000L);
fixture.listener.reset(fixture.id);
fixture.listener.captureSafeAnchor(fixture.player, fixture.anchor);
for (int episode = 1; episode <= 5; episode++) {
when(fixture.server.getCurrentTick()).thenReturn(episode + 10);
fixture.listener.observe(alert(fixture.id));
}
verify(fixture.player).teleport(fixture.anchor);
fixture.listener.recordDamage(fixture.player);
assertEquals(0, fixture.listener.holdRemainingSeconds(fixture.id, fixture.clock.get()));
verify(fixture.player, times(1)).teleport(any(Location.class));
}
private static Fixture fixture(EnforcementMode mode) {
var server = mock(Server.class);
var player = mock(Player.class);
var world = mock(World.class);
var id = UUID.randomUUID();
var anchor = new Location(world, 0, 64, 0, 10, 5);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(anchor);
when(player.teleport(any(Location.class))).thenReturn(true);
when(server.getPlayer(id)).thenReturn(player);
var clock = new AtomicLong(1_000_000_000L);
var settings = new AtomicReference<>(new EnforcementSettings(mode, 600, 3, 5, 7, 10, 10,
EnumSet.of(SIGNAL)));
var listener = new BukkitEnforcement(server, settings::get, clock::get,
location -> true, EnforcementAuditSink.NONE);
return new Fixture(server, player, world, id, anchor, clock, listener);
}
private static AlertDecision alert(UUID id) { return new AlertDecision(id, 6, 1.03, 0.80); }
private record Fixture(Server server, Player player, World world, UUID id, Location anchor,
AtomicLong clock, BukkitEnforcement listener) { }
}
@@ -40,6 +40,101 @@ 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);
when(fixture.player.getAllowFlight()).thenReturn(false);
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
for (int tick = 1; tick <= 40; tick++) {
when(fixture.server.getCurrentTick()).thenReturn(tick);
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, tick * 0.1, 80, 0));
fixture.listener.sampleFlight();
}
var alerts = fixture.monitor.finishBefore(41, 2_050_000_000L);
assertEquals(1, alerts.size());
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal());
}
@Test void serverGrantedSurvivalFlightIsAuthorized() {
var fixture = fixture();
when(fixture.player.isFlying()).thenReturn(true);
when(fixture.player.getAllowFlight()).thenReturn(true);
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 2, 80, 0));
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
fixture.listener.sampleFlight();
assertTrue(fixture.monitor.finishBefore(2, 100_000_000L).isEmpty());
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty());
}
@Test void solidServerGeometryStopsUnsupportedFlightEvidence() {
var fixture = fixture(SupportState.SUPPORTED);
when(fixture.player.isFlying()).thenReturn(true);
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 0, 80, 0));
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
for (int tick = 1; tick <= 50; tick++) {
when(fixture.server.getCurrentTick()).thenReturn(tick);
fixture.listener.sampleFlight();
}
assertTrue(fixture.monitor.finishBefore(51, 2_550_000_000L).isEmpty());
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 2_550_000_000L).isEmpty());
}
@Test void unknownGeometryIsExcludedInsteadOfTreatedAsUnsupported() {
var fixture = fixture(SupportState.UNKNOWN);
when(fixture.player.isFlying()).thenReturn(true);
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 0, 80, 0));
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
for (int tick = 1; tick <= 50; tick++) {
when(fixture.server.getCurrentTick()).thenReturn(tick);
fixture.listener.sampleFlight();
}
assertTrue(fixture.monitor.finishBefore(51, 2_550_000_000L).isEmpty());
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 2_550_000_000L).isEmpty());
}
@Test void lifecycleVelocityQuitAndCancelledMovementArePassiveExemptions() {
var fixture = fixture();
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 2, true));
@@ -55,7 +150,7 @@ class BukkitMovementListenerTest {
return Stream.of(
Arguments.of("creative", (java.util.function.Consumer<Fixture>) f -> when(f.player.getGameMode()).thenReturn(GameMode.CREATIVE)),
Arguments.of("vehicle", (java.util.function.Consumer<Fixture>) f -> when(f.player.isInsideVehicle()).thenReturn(true)),
Arguments.of("flying", (java.util.function.Consumer<Fixture>) f -> when(f.player.isFlying()).thenReturn(true)),
Arguments.of("authorized flight", (java.util.function.Consumer<Fixture>) f -> when(f.player.getAllowFlight()).thenReturn(true)),
Arguments.of("gliding", (java.util.function.Consumer<Fixture>) f -> when(f.player.isGliding()).thenReturn(true)),
Arguments.of("swimming", (java.util.function.Consumer<Fixture>) f -> when(f.player.isSwimming()).thenReturn(true)),
Arguments.of("crawling", (java.util.function.Consumer<Fixture>) f -> when(f.player.getPose()).thenReturn(Pose.SWIMMING)),
@@ -65,6 +160,10 @@ class BukkitMovementListenerTest {
}
private static Fixture fixture() {
return fixture(SupportState.UNSUPPORTED);
}
private static Fixture fixture(SupportState support) {
var server = mock(Server.class);
when(server.getCurrentTick()).thenReturn(1);
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
@@ -74,6 +173,7 @@ class BukkitMovementListenerTest {
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mock(org.bukkit.block.Block.class, invocation -> {
if (invocation.getMethod().getName().equals("getType")) return org.bukkit.Material.AIR;
if (invocation.getMethod().getName().equals("isLiquid")) return false;
if (invocation.getMethod().getName().equals("isPassable")) return true;
return RETURNS_DEFAULTS.answer(invocation);
}));
var player = mock(Player.class);
@@ -82,8 +182,10 @@ class BukkitMovementListenerTest {
when(player.getPose()).thenReturn(Pose.STANDING);
var monitor = new MovementMonitor(new MovementDetector());
var clock = new AtomicLong(50_000_000L);
var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get, (from, to) -> true);
return new Fixture(server, world, player, monitor, listener);
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, 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) {
@@ -92,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,282 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.bukkit.Server;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.yaml.snakeyaml.Yaml;
class CloudEventLogTest {
private static final Instant FIRST_DAY = Instant.parse("2026-10-29T23:59:58Z");
@TempDir Path directory;
@Test
void persistsOneStructuredCloudEventWhenNoAdministratorIsOnline() throws Exception {
var clock = Clock.fixed(FIRST_DAY, ZoneOffset.UTC);
var subjectId = UUID.fromString("39f42621-37ca-42de-a303-a2abdd2218f4");
var server = mock(Server.class);
when(server.getPlayer(subjectId)).thenReturn(null);
when(server.getOnlinePlayers()).thenReturn(List.of());
try (var events = new CloudEventLog(directory.resolve("events"), clock, warning -> fail(warning))) {
new SentinelAlerts(server, events).send(
AlertDecision.contextual(subjectId, 6, 0.34, 0.22, 10));
}
Path file = directory.resolve("events/2026-10-29.jsonl");
var lines = Files.readAllLines(file);
assertEquals(1, lines.size());
@SuppressWarnings("unchecked")
var event = (java.util.Map<String, Object>) new Yaml().load(lines.getFirst());
assertEquals("1.0", event.get("specversion"));
assertEquals("2026-10-29T23:59:58Z", event.get("time"));
assertEquals("urn:somc:purpur-sentinel", event.get("source"));
assertEquals("games.dmg.purpursentinel.alert.contextual-ground-speed", event.get("type"));
assertEquals(subjectId.toString(), event.get("subject"));
assertEquals("application/json", event.get("datacontenttype"));
assertNotNull(event.get("id"));
assertFalse(lines.getFirst().contains("Alex"));
}
@Test
void persistsPrivacyLimitedEnforcementAuditsWithoutCoordinatesOrNames() throws Exception {
var subjectId = UUID.fromString("39f42621-37ca-42de-a303-a2abdd2218f4");
try (var log = new CloudEventLog(directory.resolve("events"), Clock.fixed(FIRST_DAY, ZoneOffset.UTC),
warning -> fail(warning))) {
log.offer(new EnforcementAudit(subjectId, DetectionSignal.UNSUPPORTED_HOVER, EnforcementAction.SKIPPED,
5, 600, 10, "unsafe-anchor"));
}
String line = Files.readAllLines(directory.resolve("events/2026-10-29.jsonl")).getFirst();
assertTrue(line.contains("games.dmg.purpursentinel.enforcement.skipped"));
assertTrue(line.contains(subjectId.toString()));
assertTrue(line.contains("UNSUPPORTED_HOVER"));
assertTrue(line.contains("unsafe-anchor"));
assertFalse(line.contains("Alex"));
assertFalse(line.contains("coordinate"));
assertFalse(line.contains("anchor" + "X"));
}
@Test
void appendsAfterRestartAndRemovesAnExistingPartialTail() throws Exception {
Path events = directory.resolve("events");
Files.createDirectories(events);
Path file = events.resolve("2026-10-29.jsonl");
Files.writeString(file, "partial");
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY, ZoneOffset.UTC), warning -> fail(warning))) {
log.offer(alert);
}
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY.plusSeconds(1), ZoneOffset.UTC), warning -> fail(warning))) {
log.offer(alert);
}
var lines = Files.readAllLines(file);
assertEquals(2, lines.size());
assertTrue(lines.getFirst().startsWith("{\"specversion\":\"1.0\""));
assertTrue(lines.get(1).startsWith("{\"specversion\":\"1.0\""));
@SuppressWarnings("unchecked")
var first = (java.util.Map<String, Object>) new Yaml().load(lines.getFirst());
@SuppressWarnings("unchecked")
var second = (java.util.Map<String, Object>) new Yaml().load(lines.get(1));
assertNotEquals(first.get("id"), second.get("id"));
}
@Test
void rollsOverAtUtcMidnightAndRetainsOnlyThirtyNamedUtcDayFiles() throws Exception {
Path events = directory.resolve("events");
Files.createDirectories(events);
Files.writeString(events.resolve("2026-09-30.jsonl"), "old\n");
Files.writeString(events.resolve("2026-10-01.jsonl"), "boundary\n");
Files.writeString(events.resolve("notes.jsonl"), "keep\n");
var clock = new MutableClock(FIRST_DAY);
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
try (var log = new CloudEventLog(events, clock, warning -> fail(warning))) {
log.offer(alert);
await(() -> Files.exists(events.resolve("2026-10-29.jsonl")));
clock.set(Instant.parse("2026-10-30T00:00:01Z"));
log.offer(alert);
}
assertEquals(1, Files.readAllLines(events.resolve("2026-10-29.jsonl")).size());
assertEquals(1, Files.readAllLines(events.resolve("2026-10-30.jsonl")).size());
assertFalse(Files.exists(events.resolve("2026-09-30.jsonl")));
assertTrue(Files.exists(events.resolve("2026-10-01.jsonl")));
assertTrue(Files.exists(events.resolve("notes.jsonl")));
}
@Test
void everySignalProducesValidUuidOnlyBoundedDataWithJsonEscaping() throws Exception {
Path events = directory.resolve("events");
var id = UUID.randomUUID();
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY, ZoneOffset.UTC), warning -> fail(warning))) {
for (var signal : DetectionSignal.values()) {
log.offer(new AlertDecision(id, signal, 3, 1.5, 0.8, 20, 4.5, 2.0));
}
log.offer(new AlertDecision(id, DetectionSignal.UNSUPPORTED_BOAT_CRUISE,
0, 0, 0, 0, 0, 40, 12, 0, "BAMBOO_\"RAFT\\TEST"));
}
var lines = Files.readAllLines(events.resolve("2026-10-29.jsonl"));
assertEquals(DetectionSignal.values().length + 1, lines.size());
for (String line : lines) {
@SuppressWarnings("unchecked")
var event = (java.util.Map<String, Object>) new Yaml().load(line);
@SuppressWarnings("unchecked")
var data = (java.util.Map<String, Object>) event.get("data");
assertEquals(id.toString(), event.get("subject"));
assertTrue(event.get("type").toString().startsWith("games.dmg.purpursentinel.alert."));
assertNotNull(data.get("signal"));
assertFalse(line.contains("displayName"));
assertFalse(line.contains("coordinate"));
assertFalse(line.contains("inventory"));
assertFalse(line.contains("permission"));
}
assertTrue(lines.getLast().contains("BAMBOO_\\\"RAFT\\\\TEST"));
}
@Test
void queueSaturationDropsOnlyNewRecordAndRateLimitsWarnings() throws Exception {
var warnings = new ArrayList<String>();
var gate = new CountDownLatch(1);
var entered = new CountDownLatch(1);
CloudEventLog.EventStore blocked = (observedAt, alert) -> {
entered.countDown();
try { gate.await(); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
};
var clock = new MutableClock(FIRST_DAY);
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
var log = new CloudEventLog(clock, warnings::add, 1, java.time.Duration.ofSeconds(1), blocked);
try {
log.offer(alert);
assertTrue(entered.await(1, TimeUnit.SECONDS));
log.offer(alert);
log.offer(alert);
log.offer(alert);
assertEquals(1, log.queuedEventCount());
gate.countDown();
await(() -> warnings.size() == 1);
assertFalse(warnings.getFirst().contains(alert.playerId().toString()));
} finally {
gate.countDown();
log.close();
}
}
@Test
void closeIsBoundedStopsAcceptanceAndClearsQueuedState() throws Exception {
var gate = new CountDownLatch(1);
var entered = new CountDownLatch(1);
CloudEventLog.EventStore blocked = (observedAt, alert) -> {
entered.countDown();
try { gate.await(); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
};
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
var log = new CloudEventLog(new MutableClock(FIRST_DAY), ignored -> {}, 2,
java.time.Duration.ofMillis(25), blocked);
log.offer(alert);
assertTrue(entered.await(1, TimeUnit.SECONDS));
log.offer(alert);
long started = System.nanoTime();
log.close();
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
log.offer(alert);
assertTrue(elapsedMillis < 500, "shutdown must remain bounded");
assertEquals(0, log.queuedEventCount());
gate.countDown();
}
@Test
void directoryFailuresFailOpenWithoutLeakingPathsOrSubjectData() throws Exception {
Path events = directory.resolve("events");
Files.writeString(events, "not a directory");
var warnings = new ArrayList<String>();
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY, ZoneOffset.UTC), warnings::add)) {
log.offer(alert);
}
assertEquals(List.of("Sentinel could not write a durable alert; detection remains active."), warnings);
assertFalse(warnings.getFirst().contains(events.toString()));
assertFalse(warnings.getFirst().contains(alert.playerId().toString()));
}
@Test
void retentionFailuresHaveASeparateRateLimitedWarningAndRemainFailOpen() throws Exception {
var warnings = new ArrayList<String>();
var attempts = new AtomicInteger();
var completed = new CountDownLatch(1);
CloudEventLog.EventStore failing = (observedAt, alert) -> {
attempts.incrementAndGet();
completed.countDown();
throw new CloudEventLog.RetentionException(new java.io.IOException("sensitive retention detail"));
};
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
try (var log = new CloudEventLog(new MutableClock(FIRST_DAY), warnings::add, 4,
java.time.Duration.ofSeconds(1), failing)) {
log.offer(alert);
assertTrue(completed.await(1, TimeUnit.SECONDS));
}
assertEquals(1, attempts.get());
assertEquals(List.of("Sentinel could not apply event log retention; detection remains active."), warnings);
}
@Test
void writeFailuresAreRateLimitedAndDoNotRejectLaterOffers() throws Exception {
var warnings = new ArrayList<String>();
var attempts = new AtomicInteger();
var completed = new CountDownLatch(2);
CloudEventLog.EventStore failing = (observedAt, alert) -> {
attempts.incrementAndGet();
completed.countDown();
throw new java.io.IOException("sensitive failure detail");
};
var clock = new MutableClock(FIRST_DAY);
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
try (var log = new CloudEventLog(clock, warnings::add, 4, java.time.Duration.ofSeconds(1), failing)) {
log.offer(alert);
log.offer(alert);
assertTrue(completed.await(1, TimeUnit.SECONDS));
}
assertEquals(2, attempts.get());
assertEquals(1, warnings.size());
assertFalse(warnings.getFirst().contains("sensitive failure detail"));
assertFalse(warnings.getFirst().contains(alert.playerId().toString()));
}
private static void await(java.util.function.BooleanSupplier condition) throws InterruptedException {
for (int attempt = 0; attempt < 100 && !condition.getAsBoolean(); attempt++) Thread.sleep(10);
assertTrue(condition.getAsBoolean());
}
private static final class MutableClock extends Clock {
private volatile Instant instant;
private MutableClock(Instant instant) { this.instant = instant; }
private void set(Instant instant) { this.instant = instant; }
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
@Override public Instant instant() { return instant; }
}
}
@@ -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;
}
}
@@ -22,13 +22,27 @@ class DistributionTest {
assertEquals("26.3", metadata.get("api-version"));
assertEquals(1, ((Map<?, ?>) metadata.get("commands")).size());
var permissions = (Map<?, ?>) metadata.get("permissions");
assertEquals(java.util.Set.of("sentinel.alerts", "sentinel.inspect", "sentinel.config"), permissions.keySet());
assertEquals(java.util.Set.of("sentinel.alerts", "sentinel.inspect", "sentinel.config",
"sentinel.enforcement"), permissions.keySet());
for (Object value : permissions.values()) assertEquals("op", ((Map<?, ?>) value).get("default"));
assertNull(metadata.get("depend"));
assertNull(metadata.get("softdepend"));
assertNotNull(jar.getEntry("config.yml"));
assertTrue(new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes())
.contains("horizontal-speed-limit: 0.80"));
String config = new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes());
assertTrue(config.contains("horizontal-speed-limit: 0.80"));
assertTrue(config.contains("unsupported-flight-ticks: 40"));
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"));
assertTrue(config.contains("enforcement-mode: off"));
assertTrue(config.contains("enforcement-window-seconds: 600"));
assertTrue(config.contains("enforcement-hold-seconds: 10"));
var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class");
assertNotNull(entry);
try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
@@ -0,0 +1,161 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class ElytraMonitorTest {
private static final UUID PLAYER = UUID.randomUUID();
private static final UUID WORLD = UUID.randomUUID();
private static final UUID ELYTRA = UUID.randomUUID();
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
@Test void highHorizontalSpeedAndOrdinaryDescendingGlideNeverCreateEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
double x = 0;
double y = 120;
for (int tick = 1; tick <= 100; tick++) {
monitor.record(PLAYER, frame(tick, x, y, x + 2.5, y - 0.2, false, false,
false, false, false, 0, tick % 20 == 0), SETTINGS);
x += 2.5;
y -= 0.2;
}
assertTrue(monitor.finishBefore(101, nanos(101)).isEmpty());
assertTrue(monitor.inspect(PLAYER, nanos(101)).isEmpty());
}
@Test void repeatedSourcelessAscentAlertsWithExplainableEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
double x = 0;
double y = 80;
for (int tick = 1; tick <= 6; tick++) {
monitor.record(PLAYER, frame(tick, x, y, x + 0.4 + tick * 0.2, y + 0.2,
false, false, false, false, false, 0, false), SETTINGS);
x += 0.4 + tick * 0.2;
y += 0.2;
}
var alert = monitor.finishBefore(7, nanos(7)).getFirst();
assertEquals(DetectionSignal.ELYTRA_SOURCELESS_PROPULSION, alert.signal());
var evidence = monitor.inspect(PLAYER, nanos(7)).orElseThrow().elytraEvidence();
assertEquals(6, evidence.sourcelessTicks());
assertTrue(evidence.ascent() >= 1.2 - 1e-9);
assertTrue(evidence.peakAcceleration() > 0.1);
assertEquals(0, evidence.observedBoosts());
}
@Test void rocketRiptideVelocityExplosionAndAltitudeConversionExcludePropulsion() {
var contexts = new boolean[][] {
{true, false, false, false},
{false, true, false, false},
{false, false, true, false},
{false, false, false, true}
};
for (boolean[] context : contexts) {
var monitor = new MovementMonitor(new MovementDetector());
double x = 0;
double y = 80;
for (int tick = 1; tick <= 20; tick++) {
monitor.record(PLAYER, frame(tick, x, y, x + tick * 0.3, y + 0.2,
context[0], context[1], context[2], context[3], false, 0, false), SETTINGS);
x += tick * 0.3;
y += 0.2;
}
assertTrue(monitor.finishBefore(21, nanos(21)).isEmpty());
assertTrue(monitor.inspect(PLAYER, nanos(21)).isEmpty());
}
}
@Test void observedRocketCreatesCorrelationGraceWithoutTrustingCarriedInventory() {
var monitor = new MovementMonitor(new MovementDetector());
double x = 0;
double y = 80;
for (int tick = 1; tick <= 20; tick++) {
monitor.record(PLAYER, frame(tick, x, y, x + tick * .3, y + .2,
tick == 1, false, false, false, false, 0, false), SETTINGS);
x += tick * .3;
y += .2;
}
assertTrue(monitor.finishBefore(21, nanos(21)).isEmpty());
}
@Test void wearRequiresSixtyEligibleOpportunitiesAndAccountsForUnbreaking() {
var plain = new MovementMonitor(new MovementDetector());
for (int opportunity = 1; opportunity <= 59; opportunity++) {
plain.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
}
assertTrue(plain.finishBefore(60, nanos(60)).isEmpty());
plain.record(PLAYER, wearFrame(60, 0, false, false, false), SETTINGS);
assertEquals(DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR,
plain.finishBefore(61, nanos(61)).getFirst().signal());
var unbreaking = new MovementMonitor(new MovementDetector());
for (int opportunity = 1; opportunity <= 60; opportunity++) {
unbreaking.record(PLAYER, wearFrame(opportunity, 3, false, false, false), SETTINGS);
}
assertTrue(unbreaking.finishBefore(61, nanos(61)).isEmpty());
for (int opportunity = 61; opportunity <= 240; opportunity++) {
unbreaking.record(PLAYER, wearFrame(opportunity, 3, false, false, false), SETTINGS);
}
assertEquals(DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR,
unbreaking.finishBefore(241, nanos(241)).getFirst().signal());
}
@Test void observedWearMendingXpUnbreakableSwapAndInterruptionResetWearEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
for (int opportunity = 1; opportunity <= 59; opportunity++) {
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
}
monitor.record(PLAYER, wearFrame(60, 0, true, false, false), SETTINGS);
for (int opportunity = 61; opportunity <= 119; opportunity++) {
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
}
monitor.record(PLAYER, wearFrame(120, 0, false, true, false), SETTINGS);
for (int opportunity = 121; opportunity <= 179; opportunity++) {
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
}
monitor.record(PLAYER, wearFrame(180, 0, false, false, true), SETTINGS);
assertTrue(monitor.finishBefore(181, nanos(181)).isEmpty());
monitor.record(PLAYER, new ElytraFrame(181, nanos(181), WORLD, UUID.randomUUID(), false,
0, 80, 0, 0, 80, 0, false, false, false, false,
false, false, false, 0, false, ExclusionReason.NONE), SETTINGS);
assertTrue(monitor.inspect(PLAYER, nanos(181)).isEmpty());
}
@Test void disconnectAndDisableClearAllElytraState() {
var monitor = new MovementMonitor(new MovementDetector());
for (int opportunity = 1; opportunity <= 59; opportunity++) {
monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS);
}
monitor.forget(PLAYER);
assertTrue(monitor.inspect(PLAYER, nanos(60)).isEmpty());
monitor.record(PLAYER, wearFrame(61, 0, false, false, false), SETTINGS);
monitor.clear();
assertTrue(monitor.inspect(PLAYER, nanos(62)).isEmpty());
}
private static ElytraFrame wearFrame(int opportunity, int unbreaking, boolean wearObserved,
boolean repairObserved, boolean unbreakable) {
return frame(opportunity, 0, 80, 0.2, 79.9, false, false, false, false,
unbreakable, unbreaking, true, wearObserved, repairObserved);
}
private static ElytraFrame frame(int tick, double fromX, double fromY, double toX, double toY,
boolean boost, boolean riptide, boolean velocity, boolean explosion, boolean unbreakable,
int unbreaking, boolean wearOpportunity) {
return frame(tick, fromX, fromY, toX, toY, boost, riptide, velocity, explosion,
unbreakable, unbreaking, wearOpportunity, false, false);
}
private static ElytraFrame frame(int tick, double fromX, double fromY, double toX, double toY,
boolean boost, boolean riptide, boolean velocity, boolean explosion, boolean unbreakable,
int unbreaking, boolean wearOpportunity, boolean wearObserved, boolean repairObserved) {
return new ElytraFrame(tick, nanos(tick), WORLD, ELYTRA, true,
fromX, fromY, 0, toX, toY, 0, boost, riptide, velocity, explosion,
wearOpportunity, wearObserved, repairObserved, unbreaking, unbreakable,
ExclusionReason.NONE);
}
private static long nanos(int tick) { return tick * 50_000_000L; }
}
@@ -0,0 +1,63 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EnforcementCommandTest {
@TempDir Path directory;
@Test
void operatorCanReadAndPersistDefaultOffGraduatedPolicyThroughCommands() throws Exception {
Path file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var settings = SettingsService.load(new AtomicYamlSettingsStore(file));
var enforcement = EnforcementService.load(new AtomicYamlSettingsStore(file), settings);
var server = mock(Server.class);
var sender = mock(CommandSender.class);
when(sender.hasPermission("sentinel.enforcement")).thenReturn(true);
var handler = new SentinelCommand(server, new MovementMonitor(new MovementDetector()), settings,
enforcement, () -> 0L);
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "status"}));
verify(sender).sendMessage(contains("mode off"));
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "mode", "setback-kick"}));
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "hold-seconds", "60"}));
var reloaded = EnforcementService.load(new AtomicYamlSettingsStore(file), settings);
assertEquals(EnforcementMode.SETBACK_KICK, reloaded.current().mode());
assertEquals(60, reloaded.current().holdSeconds());
assertTrue(Files.readString(file).contains("enforcement-mode: setback-kick"));
assertEquals(List.of("enforcement"), handler.onTabComplete(sender, mock(Command.class), "sentinel",
new String[]{"enf"}));
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "signal", "unsupported-boat-hover", "on"}));
verify(sender).sendMessage(contains("alert-only"));
assertFalse(enforcement.current().eligibleSignals().contains(DetectionSignal.UNSUPPORTED_BOAT_HOVER));
var denied = mock(CommandSender.class);
assertTrue(handler.onCommand(denied, mock(Command.class), "sentinel",
new String[]{"enforcement", "mode", "kick"}));
verify(denied).sendMessage(contains("do not have permission"));
var beforeFailure = enforcement.current();
Files.delete(file);
Files.createDirectory(file);
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "hold-seconds", "30"}));
assertEquals(beforeFailure, enforcement.current());
verify(sender).sendMessage(contains("prior value remains active"));
}
}
@@ -0,0 +1,108 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EnforcementMonitorTest {
private static final UUID PLAYER = UUID.randomUUID();
private static final DetectionSignal SIGNAL = DetectionSignal.ABSOLUTE_GROUND_SPEED;
@Test
void countsSameSignalEpisodesAndFiresEachGraduatedThresholdOnce() {
var audits = new ArrayList<EnforcementAudit>();
var monitor = new EnforcementMonitor(audits::add);
var settings = settings(EnforcementMode.SETBACK_KICK);
EnforcementDecision decision = EnforcementDecision.none();
for (int episode = 1; episode <= 7; episode++) {
decision = monitor.observe(PLAYER, SIGNAL, episode * 1_000_000_000L, settings,
episode >= 5, false);
if (episode == 3) assertEquals(EnforcementAction.WARNING, decision.action());
else if (episode == 5) assertEquals(EnforcementAction.SETBACK, decision.action());
else if (episode == 7) assertEquals(EnforcementAction.KICK, decision.action());
else assertEquals(EnforcementAction.NONE, decision.action());
}
assertEquals(7, decision.episodeCount());
assertEquals(List.of(EnforcementAction.WARNING, EnforcementAction.SETBACK, EnforcementAction.KICK),
audits.stream().map(EnforcementAudit::action).toList());
}
@Test
void separatesSignalsExpiresWindowAndNeverEnforcesIneligibleOrOffSignals() {
var monitor = new EnforcementMonitor(EnforcementAuditSink.NONE);
var settings = settings(EnforcementMode.KICK);
for (int episode = 1; episode <= 2; episode++) {
monitor.observe(PLAYER, SIGNAL, episode, settings, false, false);
monitor.observe(PLAYER, DetectionSignal.CONTEXTUAL_GROUND_SPEED, episode, settings, false, false);
}
assertEquals(2, monitor.inspect(PLAYER, 3, settings).getFirst().episodeCount());
assertTrue(monitor.observe(PLAYER, DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR, 4, settings,
false, false).isNone());
assertTrue(monitor.observe(PLAYER, SIGNAL, 700_000_000_000L, settings,
false, false).isNone());
assertEquals(1, monitor.inspect(PLAYER, 700_000_000_001L, settings).getFirst().episodeCount());
assertTrue(monitor.observe(PLAYER, SIGNAL, 700_000_000_002L,
settings(EnforcementMode.OFF), false, false).isNone());
}
@Test
void deduplicatesMultipleSameSignalAlertsWithinOneServerTick() {
var monitor = new EnforcementMonitor(EnforcementAuditSink.NONE);
var settings = settings(EnforcementMode.WARN);
for (int tick = 1; tick <= 2; tick++) {
assertTrue(monitor.observe(PLAYER, SIGNAL, tick, tick, settings, true, false).isNone());
assertTrue(monitor.observe(PLAYER, SIGNAL, tick + 1, tick, settings, true, false).isNone());
}
var warning = monitor.observe(PLAYER, SIGNAL, 3, 3, settings, true, false);
assertEquals(EnforcementAction.WARNING, warning.action());
assertEquals(3, warning.episodeCount());
}
@Test
void everyModeExecutesOnlyItsApprovedStages() {
for (EnforcementMode mode : EnforcementMode.values()) {
var actions = new ArrayList<EnforcementAudit>();
var monitor = new EnforcementMonitor(actions::add);
UUID player = UUID.randomUUID();
for (int episode = 1; episode <= 7; episode++) {
monitor.observe(player, SIGNAL, episode, settings(mode), true, false);
}
if (mode == EnforcementMode.OFF) assertTrue(actions.isEmpty());
if (mode == EnforcementMode.WARN) assertTrue(actions.stream().allMatch(
audit -> audit.action() == EnforcementAction.WARNING));
if (mode == EnforcementMode.SETBACK) assertTrue(actions.stream().noneMatch(
audit -> audit.action() == EnforcementAction.KICK));
if (mode == EnforcementMode.KICK) assertTrue(actions.stream().noneMatch(
audit -> audit.action() == EnforcementAction.SETBACK));
if (mode == EnforcementMode.SETBACK_KICK) {
assertTrue(actions.stream().anyMatch(audit -> audit.action() == EnforcementAction.SETBACK));
assertTrue(actions.stream().anyMatch(audit -> audit.action() == EnforcementAction.KICK));
}
}
}
@Test
void recentDamageOrMissingAnchorSkipsSetbackWithoutBlockingLaterKick() {
var audits = new ArrayList<EnforcementAudit>();
var monitor = new EnforcementMonitor(audits::add);
var settings = settings(EnforcementMode.SETBACK_KICK);
for (int episode = 1; episode <= 4; episode++) {
monitor.observe(PLAYER, SIGNAL, episode, settings, false, false);
}
var skipped = monitor.observe(PLAYER, SIGNAL, 5, settings, false, true);
assertEquals(EnforcementAction.SKIPPED, skipped.action());
assertEquals("recent-damage", skipped.reason());
monitor.observe(PLAYER, SIGNAL, 6, settings, true, false);
var kicked = monitor.observe(PLAYER, SIGNAL, 7, settings, true, false);
assertEquals(EnforcementAction.KICK, kicked.action());
}
private static EnforcementSettings settings(EnforcementMode mode) {
return new EnforcementSettings(mode, 600, 3, 5, 7, 10, 10,
EnumSet.of(SIGNAL, DetectionSignal.CONTEXTUAL_GROUND_SPEED));
}
}
@@ -0,0 +1,218 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class FlightMonitorTest {
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
@Test void activeFlightSequenceSurvivesPerTickCleanupUntilItAlerts() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
java.util.List<AlertDecision> alerts = java.util.List.of();
for (int tick = 1; tick <= 40; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.3, 80, 0, false, true), SETTINGS);
alerts = monitor.finishBefore(tick + 1, tick * 50_000_000L + 1);
}
assertEquals(1, alerts.size());
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal());
}
@Test void sustainedUnauthorizedFlyingStateAlertsWithExplainableEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
for (int tick = 1; tick <= 40; tick++) {
monitor.record(player, FlightFrame.unsupported(
tick, tick * 50_000_000L, world,
0, 80, 0, 0.1, 80, 0,
false, true), SETTINGS);
}
var alerts = monitor.finishBefore(41, 2_050_000_000L);
assertEquals(1, alerts.size());
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal());
assertEquals(40, alerts.getFirst().eligibleTicks());
assertEquals(4.0, alerts.getFirst().horizontalDistance(), 1e-9);
assertEquals(0.0, alerts.getFirst().verticalDisplacement(), 1e-9);
var evidence = monitor.inspect(player, 2_050_000_000L).orElseThrow().flightEvidence();
assertFalse(evidence.authorized());
assertFalse(evidence.supported());
assertEquals(40, evidence.eligibleTicks());
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, evidence.signal());
}
@Test void sustainedAscentHoverAndCruiseProduceDistinctSignals() {
assertEquals(DetectionSignal.UNSUPPORTED_ASCENT,
alertFor(40, 0, 0.06, false, SETTINGS).signal());
assertEquals(DetectionSignal.UNSUPPORTED_HOVER,
alertFor(30, 0.01, 0, false, SETTINGS).signal());
assertEquals(DetectionSignal.UNSUPPORTED_CRUISE,
alertFor(40, 0.3, 0, false, SETTINGS).signal());
}
@Test void ordinaryJumpAndFallTrajectoryNeverAlerts() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
double y = 64;
double verticalVelocity = 0.42;
for (int tick = 1; tick <= 18; tick++) {
double nextY = y + verticalVelocity;
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, y, 0, 0.1, nextY, 0, false, false), SETTINGS);
y = nextY;
verticalVelocity = (verticalVelocity - 0.08) * 0.98;
}
assertTrue(monitor.finishBefore(19, 950_000_000L).isEmpty());
}
@Test void exclusionStartsGraceAndEvidenceExpiresWithoutMoreMovement() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
monitor.record(player, new FlightFrame(1, 50_000_000L, world,
0, 80, 0, 0, 80, 0, false, false,
SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS);
for (int tick = 2; tick <= 21; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.4, 80, 0, false, false), SETTINGS);
}
assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty());
assertTrue(monitor.inspect(player, 1_100_000_000L).isEmpty());
for (int tick = 22; tick <= 61; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.4, 80, 0, false, false), SETTINGS);
}
assertEquals(DetectionSignal.UNSUPPORTED_CRUISE,
monitor.finishBefore(62, 3_100_000_000L).getFirst().signal());
assertTrue(monitor.inspect(player, 3_100_000_000L).isPresent());
monitor.finishBefore(300, 20_000_000_000L);
assertTrue(monitor.inspect(player, 20_000_000_000L).isEmpty());
}
@Test void disconnectAndDisableClearFlightEvidence() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
for (int tick = 1; tick <= 40; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
}
assertTrue(monitor.inspect(player, 2_000_000_000L).isPresent());
monitor.forget(player);
assertTrue(monitor.inspect(player, 50_000_000L).isEmpty());
for (int tick = 41; tick <= 80; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
}
monitor.clear();
assertTrue(monitor.inspect(player, 4_000_000_000L).isEmpty());
}
@Test void externalLifecycleExemptionStartsFlightGrace() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
monitor.exempt(player, 1, 50_000_000L, ExclusionReason.VELOCITY, SETTINGS);
for (int tick = 2; tick <= 21; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 1, 80, 0, false, true), SETTINGS);
}
assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty());
assertTrue(monitor.inspect(player, 1_100_000_000L).isEmpty());
}
@Test void everyApprovedExclusionAndUnknownSupportResetsFlightEvidenceWithGrace() {
for (ExclusionReason reason : java.util.List.of(
ExclusionReason.GAME_MODE, ExclusionReason.VEHICLE, ExclusionReason.FLYING,
ExclusionReason.GLIDING, ExclusionReason.SWIMMING, ExclusionReason.CLIMBING,
ExclusionReason.RIPTIDE, ExclusionReason.POTION_EFFECT, ExclusionReason.ENVIRONMENT,
ExclusionReason.LOW_TPS, ExclusionReason.LIFECYCLE, ExclusionReason.VELOCITY,
ExclusionReason.DAMAGE)) {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
monitor.record(player, new FlightFrame(1, 50_000_000L, world,
0, 80, 0, 1, 80, 0, false, false,
SupportState.UNSUPPORTED, reason), SETTINGS);
for (int tick = 2; tick <= 21; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 1, 80, 0, false, true), SETTINGS);
}
assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty(), reason.toString());
}
}
@Test void duplicateAndDisconnectedTicksCannotInflateOrPreserveFlightSequence() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
for (int tick = 1; tick <= 39; tick++) {
var frame = FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.5, 80, 0, false, true);
monitor.record(player, frame, SETTINGS);
monitor.record(player, frame, SETTINGS);
}
assertTrue(monitor.finishBefore(40, 2_000_000_000L).isEmpty());
monitor.record(player, FlightFrame.unsupported(41, 2_050_000_000L, world,
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
assertTrue(monitor.finishBefore(42, 2_100_000_000L).isEmpty());
}
@Test void changingAuthorizationClearsPriorUnsupportedSequence() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
for (int tick = 1; tick <= 39; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
}
monitor.record(player, FlightFrame.unsupported(40, 2_000_000_000L, world,
0, 80, 0, 0.5, 80, 0, true, true), SETTINGS);
for (int tick = 41; tick <= 79; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
}
assertTrue(monitor.finishBefore(80, 4_000_000_000L).isEmpty());
}
@Test void survivalFlightGrantedByTheServerIsAuthorizedAndNeverAlerts() {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
for (int tick = 1; tick <= 50; tick++) {
monitor.record(player, FlightFrame.unsupported(
tick, tick * 50_000_000L, world,
0, 80, 0, 0.2, 80.1, 0,
true, true), SETTINGS);
}
assertTrue(monitor.finishBefore(51, 2_550_000_000L).isEmpty());
assertTrue(monitor.inspect(player, 2_550_000_000L).isEmpty());
}
private static AlertDecision alertFor(int ticks, double horizontalPerTick, double verticalPerTick,
boolean flying, SentinelSettings settings) {
var monitor = new MovementMonitor(new MovementDetector());
var player = UUID.randomUUID();
var world = UUID.randomUUID();
double x = 0;
double y = 80;
for (int tick = 1; tick <= ticks; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
x, y, 0, x + horizontalPerTick, y + verticalPerTick, 0, false, flying), settings);
x += horizontalPerTick;
y += verticalPerTick;
}
return monitor.finishBefore(ticks + 1, (ticks + 1) * 50_000_000L).getFirst();
}
}
@@ -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);
}
}
@@ -11,7 +11,7 @@ import org.junit.jupiter.api.io.TempDir;
class PrivacyBoundaryTest {
@TempDir Path directory;
@Test void observationAndInspectionNeverPersistPlayerIdentityOrEvidence() throws Exception {
@Test void observationAndInspectionDoNotWriteOutsideTheExplicitAlertLogBoundary() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
@@ -25,6 +25,13 @@ class PrivacyBoundaryTest {
monitor.finishBefore(tick + 1, tick * 50_000_000L + 1);
}
assertTrue(monitor.inspect(player, 400_000_000L).isPresent());
var flightWorld = UUID.randomUUID();
for (int tick = 10; tick < 50; tick++) {
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, flightWorld,
120.25, 90, -45.5, 120.55, 90, -45.5, false, true), service.current());
}
monitor.finishBefore(50, 2_500_000_000L);
assertNotNull(monitor.inspect(player, 2_500_000_000L).orElseThrow().flightEvidence());
assertArrayEquals(before, Files.readAllBytes(file));
assertEquals(java.util.Set.of("config.yml"), Files.list(directory)
.map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet()));
@@ -32,5 +39,6 @@ class PrivacyBoundaryTest {
assertFalse(disk.contains(player.toString()));
assertFalse(disk.contains("120.25"));
assertFalse(disk.contains("-45.5"));
assertFalse(disk.contains("unsupported ticks"));
}
}
@@ -29,4 +29,88 @@ class SentinelAlertsTest {
verify(denied, never()).sendMessage(anyString());
verify(server, never()).getConsoleSender();
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void contextualAlertNamesSignalExpectedLimitRatioAndWindowWithoutCoordinates() {
var server = mock(Server.class);
var subject = mock(Player.class);
var operator = mock(Player.class);
var id = UUID.randomUUID();
when(server.getPlayer(id)).thenReturn(subject);
when(subject.isOnline()).thenReturn(true);
when(subject.getName()).thenReturn("Alex");
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers();
new SentinelAlerts(server).send(AlertDecision.contextual(id, 6, 0.34, 0.22, 10));
verify(operator).sendMessage(argThat((String message) -> message.contains("CONTEXTUAL_GROUND_SPEED")
&& message.contains("0.34") && message.contains("0.22") && message.contains("1.55")
&& message.contains("6 breaches") && message.contains("10s window")
&& !message.contains("x=") && !message.contains("z=")
&& !message.toLowerCase().contains("cheat")));
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void elytraAlertDistinguishesSignalAndReportsOnlyBoundedContext() {
var server = mock(Server.class);
var subject = mock(Player.class);
var operator = mock(Player.class);
var id = UUID.randomUUID();
when(server.getPlayer(id)).thenReturn(subject);
when(subject.isOnline()).thenReturn(true);
when(subject.getName()).thenReturn("Alex");
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers();
var evidence = new ElytraEvidence(DetectionSignal.ELYTRA_SOURCELESS_PROPULSION,
20, 6, 1, 0, 3, 2.4, 0.35, 1, 60, ExclusionReason.NONE);
new SentinelAlerts(server).send(AlertDecision.elytra(id, evidence));
verify(operator).sendMessage(argThat((String message) -> message.contains("ELYTRA_SOURCELESS_PROPULSION")
&& message.contains("ascent 2.40") && message.contains("peak acceleration 0.35")
&& message.contains("observed boosts 1") && message.contains("Unbreaking 3")
&& !message.contains("x=") && !message.toLowerCase().contains("inventory")
&& !message.toLowerCase().contains("cheat")));
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void boatAlertNamesControllerVehicleSignalAndBoundedEvidenceWithoutCoordinates() {
var server = mock(Server.class);
var subject = mock(Player.class);
var operator = mock(Player.class);
var id = UUID.randomUUID();
when(server.getPlayer(id)).thenReturn(subject);
when(subject.isOnline()).thenReturn(true);
when(subject.getName()).thenReturn("Alex");
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers();
var evidence = new BoatEvidence(DetectionSignal.UNSUPPORTED_BOAT_CRUISE, UUID.randomUUID(),
"BAMBOO_RAFT", SupportState.UNSUPPORTED, 40, 0, 0, 40, 12, 0, 2, 60,
ExclusionReason.NONE);
new SentinelAlerts(server).send(AlertDecision.boat(id, evidence));
verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_CRUISE")
&& message.contains("BAMBOO_RAFT") && message.contains("40 unsupported ticks")
&& message.contains("12.00 horizontal") && !message.contains("x=")
&& !message.toLowerCase().contains("cheat")));
}
@Test @SuppressWarnings({"unchecked", "rawtypes"})
void flightAlertNamesSignalAndBoundedDisplacementWithoutAccusingOrReportingCoordinates() {
var server = mock(Server.class);
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(new AlertDecision(id, DetectionSignal.UNSUPPORTED_ASCENT,
0, 0, 0, 40, 8.5, 2.4));
verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_ASCENT")
&& message.contains("40 unsupported ticks") && message.contains("8.50 horizontal")
&& message.contains("2.40 vertical") && !message.toLowerCase().contains("cheat")
&& !message.contains("x=") && !message.contains("z=")));
}
}
@@ -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,72 @@ class SentinelCommandTest {
verify(online, never()).teleport(any(org.bukkit.Location.class));
}
@Test void inspectReportsBoundedElytraEvidenceAndExclusions() throws Exception {
var fixture = fixture();
var target = online(fixture.server, "Glider");
var sender = permitted("sentinel.inspect");
var world = UUID.randomUUID();
var elytra = UUID.randomUUID();
double x = 0;
double y = 80;
for (int tick = 1; tick <= 6; tick++) {
fixture.monitor.record(target.getUniqueId(), new ElytraFrame(tick, tick * 50_000_000L,
world, elytra, true, x, y, 0, x + tick * .2, y + .2, 0,
false, false, false, false, false, false, false, 2, false,
ExclusionReason.NONE), fixture.settings.current());
x += tick * .2;
y += .2;
}
fixture.monitor.finishBefore(7, 350_000_000L);
fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Glider"});
verify(sender).sendMessage(argThat((String message) -> message.contains("ELYTRA_SOURCELESS_PROPULSION")
&& message.contains("6 sourceless ticks") && message.contains("ascent 1.20")
&& message.contains("Unbreaking 2") && message.contains("exclusion none")
&& message.contains("cooldown") && !message.contains("80")));
}
@Test void inspectReportsBoundedBoatEvidenceSupportExclusionAndCooldown() throws Exception {
var fixture = fixture();
var target = online(fixture.server, "Boater");
var sender = permitted("sentinel.inspect");
var boat = UUID.randomUUID();
var world = UUID.randomUUID();
for (int tick = 1; tick <= 30; tick++) {
fixture.monitor.record(BoatFrame.unsupported(tick, tick * 50_000_000L, boat,
target.getUniqueId(), world, "BAMBOO_RAFT", 0, 80, 0, 0, 80, 0), fixture.settings.current());
}
fixture.monitor.finishBefore(31, 1_550_000_000L);
fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Boater"});
verify(sender).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_BOAT_HOVER")
&& message.contains("BAMBOO_RAFT") && message.contains("supported no")
&& message.contains("30 eligible ticks") && message.contains("exclusion none")
&& message.contains("cooldown") && !message.contains("80")));
}
@Test void inspectReportsBoundedFlightStateSignalsAgeAndCooldown() throws Exception {
var fixture = fixture();
var target = online(fixture.server, "Flyer");
var sender = permitted("sentinel.inspect");
var world = UUID.randomUUID();
double y = 80;
for (int tick = 1; tick <= 40; tick++) {
fixture.monitor.record(target.getUniqueId(), FlightFrame.unsupported(
tick, tick * 50_000_000L, world,
0, y, 0, 0, y + 0.06, 0, false, false), fixture.settings.current());
y += 0.06;
}
fixture.monitor.finishBefore(41, 2_050_000_000L);
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"inspect", "Flyer"}));
verify(sender).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_ASCENT")
&& message.contains("authorized no") && message.contains("supported no")
&& message.contains("40 eligible ticks")
&& message.contains("signal counts flying 0, ascent 40, hover 0, cruise 0")
&& message.contains("ascent 2.40") && message.contains("cooldown")
&& !message.contains("80")));
}
@Test void configReadsAndDurablyUpdatesBeforeFutureDetection() throws Exception {
var fixture = fixture();
var sender = permitted("sentinel.config");
@@ -14,7 +14,8 @@ class SettingsServiceTest {
@Test void validUpdatePersistsBeforePublicationAndPreservesUnrelatedYaml() throws Exception {
var file = directory.resolve("config.yml");
Files.writeString(file, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\nevidence-window-seconds: 10\nalert-cooldown-seconds: 60\nminimum-tps: 18.0\nexemption-grace-ticks: 20\nunrelated: keep-me\n");
AtomicYamlSettingsStore.writeDefaults(file);
Files.writeString(file, Files.readString(file) + "unrelated: keep-me\n");
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals("0.60", service.update(SettingKey.HORIZONTAL_SPEED_LIMIT, "0.60"));
@@ -56,6 +57,83 @@ class SettingsServiceTest {
assertEquals(service.current(), loaded.current());
}
@Test void existingVersionOneConfigurationReceivesFlightDefaultsWithoutRewrite() throws Exception {
var file = directory.resolve("config.yml");
Files.writeString(file, "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\nunrelated: keep-me\n");
byte[] before = Files.readAllBytes(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals(40, service.current().unsupportedFlightTicks());
assertEquals(30, service.current().unsupportedHoverTicks());
assertEquals(2.0, service.current().unsupportedAscentBlocks(), 1e-9);
assertEquals(10.0, service.current().unsupportedCruiseBlocks(), 1e-9);
assertArrayEquals(before, Files.readAllBytes(file));
}
@Test void flightThresholdsLoadPersistAndRejectOutOfRangeValues() throws Exception {
var file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals(40, service.current().unsupportedFlightTicks());
assertEquals(30, service.current().unsupportedHoverTicks());
assertEquals(2.0, service.current().unsupportedAscentBlocks(), 1e-9);
assertEquals(10.0, service.current().unsupportedCruiseBlocks(), 1e-9);
assertEquals("50", service.update(SettingKey.UNSUPPORTED_FLIGHT_TICKS, "50"));
assertEquals("3.5", service.update(SettingKey.UNSUPPORTED_ASCENT_BLOCKS, "3.5"));
var loaded = SettingsService.load(new AtomicYamlSettingsStore(file));
assertEquals(50, loaded.current().unsupportedFlightTicks());
assertEquals(3.5, loaded.current().unsupportedAscentBlocks(), 1e-9);
assertThrows(IllegalArgumentException.class,
() -> service.update(SettingKey.UNSUPPORTED_HOVER_TICKS, "9"));
assertThrows(IllegalArgumentException.class,
() -> 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);