Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1317b85835 | ||
|
|
2a2d307f6d | ||
|
|
8731548d5a | ||
|
|
4cebd19250 | ||
|
|
e506cb79f0 |
No files matched your search
@@ -2,7 +2,9 @@
|
||||
|
||||
Passive, explainable movement-pattern observation for Purpur servers.
|
||||
|
||||
Sentinel gives permitted operators bounded evidence of suspicious on-foot speed, unauthorized unsupported flight, unsupported boat travel, source-less elytra propulsion, and statistically implausible elytra wear. Elytra analysis correlates server-recognized gliding, equipped-item identity, observed rocket use, external propulsion, altitude conversion, wear opportunities, Unbreaking, Mending/XP repair, and unbreakable items; high speed alone is never evidence. Sentinel does not cancel movement, alter vehicles or items, consume rockets, change flight permissions, punish players, persist player evidence, or export telemetry.
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
@FunctionalInterface
|
||||
interface AlertEventSink {
|
||||
AlertEventSink NONE = alert -> {};
|
||||
|
||||
void offer(AlertDecision alert);
|
||||
}
|
||||
@@ -39,6 +39,24 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence {
|
||||
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,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) { }
|
||||
}
|
||||
@@ -35,31 +35,39 @@ public final class BukkitMovementListener implements Listener {
|
||||
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),
|
||||
MovementEnvironment::supportState);
|
||||
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);
|
||||
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);
|
||||
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;
|
||||
@@ -67,6 +75,7 @@ public final class BukkitMovementListener implements Listener {
|
||||
this.ordinaryEnvironment = ordinaryEnvironment;
|
||||
this.supportState = supportState;
|
||||
this.groundMovementContext = groundMovementContext;
|
||||
this.enforcement = enforcement;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
@@ -76,6 +85,7 @@ 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(),
|
||||
|
||||
@@ -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,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) {
|
||||
}
|
||||
@@ -66,6 +66,26 @@ final class MovementEnvironment {
|
||||
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)) {
|
||||
@@ -91,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -12,6 +13,8 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
private BukkitMovementListener listener;
|
||||
private BukkitBoatObserver boatObserver;
|
||||
private BukkitElytraObserver elytraObserver;
|
||||
private BukkitEnforcement enforcementRuntime;
|
||||
private CloudEventLog eventLog;
|
||||
private BukkitTask tickTask;
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -19,19 +22,29 @@ 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);
|
||||
getServer().getPluginManager().registerEvents(boatObserver, this);
|
||||
getServer().getPluginManager().registerEvents(elytraObserver, this);
|
||||
var alerts = new SentinelAlerts(getServer());
|
||||
getServer().getPluginManager().registerEvents(enforcementRuntime, this);
|
||||
var alerts = new SentinelAlerts(getServer(), eventLog, enforcementRuntime);
|
||||
tickTask = getServer().getScheduler().runTaskTimer(this, () -> {
|
||||
listener.sampleFlight();
|
||||
boatObserver.sample();
|
||||
@@ -41,6 +54,9 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,5 +84,14 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
monitor.clear();
|
||||
monitor = null;
|
||||
}
|
||||
if (enforcementRuntime != null) {
|
||||
enforcementRuntime.clear();
|
||||
HandlerList.unregisterAll(enforcementRuntime);
|
||||
enforcementRuntime = null;
|
||||
}
|
||||
if (eventLog != null) {
|
||||
eventLog.close();
|
||||
eventLog = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,26 @@ 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;
|
||||
|
||||
@@ -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,9 +116,21 @@ 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();
|
||||
@@ -166,6 +207,74 @@ 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";
|
||||
}
|
||||
|
||||
@@ -14,3 +14,21 @@ 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
|
||||
@@ -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;
|
||||
@@ -62,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);
|
||||
}
|
||||
@@ -101,6 +102,7 @@ class PluginLifecycleTest {
|
||||
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();
|
||||
|
||||
@@ -117,6 +119,7 @@ class PluginLifecycleTest {
|
||||
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)));
|
||||
@@ -130,12 +133,22 @@ class PluginLifecycleTest {
|
||||
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);
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ 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"));
|
||||
@@ -39,6 +40,9 @@ class DistributionTest {
|
||||
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,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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user