diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagDetector.java b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagDetector.java
new file mode 100644
index 00000000..9d3cb088
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagDetector.java
@@ -0,0 +1,183 @@
+package dev.hylfrd.farmhelper.feature.lag;
+
+import dev.hylfrd.farmhelper.macro.ServerResponsiveness;
+import dev.hylfrd.farmhelper.macro.ServerTimeTracker;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.PositionSnapshot;
+import dev.hylfrd.farmhelper.runtime.time.MonotonicClock;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Objects;
+import java.util.OptionalDouble;
+import java.util.OptionalLong;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Client-thread lag evidence and TPS history built around the shared server heartbeat tracker.
+ *
+ *
The injected tracker and clock must be the same instances used by the owning runtime. This
+ * class deliberately has no constructor that creates a private heartbeat authority.
+ */
+public final class LagDetector {
+ public static final int TPS_HISTORY_SIZE = 20;
+ public static final double MAX_TICK_RATE = 20.0d;
+ public static final long RECENT_LAG_NANOS = TimeUnit.MILLISECONDS.toNanos(900L);
+
+ private static final long NANOS_PER_SECOND = TimeUnit.SECONDS.toNanos(1L);
+ private static final long NO_TIME = -1L;
+ private static final long NO_WORLD = -1L;
+
+ private final MonotonicClock clock;
+ private final ServerTimeTracker serverTimeTracker;
+ private final Deque tickRates = new ArrayDeque<>(TPS_HISTORY_SIZE);
+
+ private long worldEpoch = NO_WORLD;
+ private long lastClockAtNanos = NO_TIME;
+ private long lastPacketAtNanos = NO_TIME;
+ private long lastLagObservedAtNanos = NO_TIME;
+ private Observation lastPacketPosition = Observation.unknown();
+
+ public LagDetector(MonotonicClock clock, ServerTimeTracker serverTimeTracker) {
+ this.clock = Objects.requireNonNull(clock, "clock");
+ this.serverTimeTracker = Objects.requireNonNull(serverTimeTracker, "serverTimeTracker");
+ }
+
+ /** Starts a fresh, world-identity-fenced server session and clears all prior evidence. */
+ public void joined(long worldEpoch) {
+ requireWorldEpoch(worldEpoch);
+ long nowNanos = readClock();
+ serverTimeTracker.joined(nowNanos);
+ this.worldEpoch = worldEpoch;
+ lastClockAtNanos = nowNanos;
+ lastPacketAtNanos = NO_TIME;
+ lastLagObservedAtNanos = NO_TIME;
+ lastPacketPosition = Observation.unknown();
+ tickRates.clear();
+ }
+
+ /**
+ * Records a heartbeat and its independently captured position evidence.
+ *
+ * Unknown or absent positions replace older position evidence; the heartbeat itself remains
+ * valid. A stale world epoch or regressed clock mutates neither the tracker nor local state.
+ */
+ public LagPacketResult recordTimePacket(LagPacketEvidence evidence) {
+ Objects.requireNonNull(evidence, "evidence");
+ if (worldEpoch == NO_WORLD) {
+ return LagPacketResult.NOT_JOINED;
+ }
+ if (evidence.worldEpoch() != worldEpoch) {
+ return LagPacketResult.STALE_IDENTITY;
+ }
+
+ long nowNanos = readClock();
+ if (nowNanos < lastClockAtNanos) {
+ return LagPacketResult.CLOCK_REGRESSION;
+ }
+
+ serverTimeTracker.receivedTimePacket(nowNanos);
+ if (lastPacketAtNanos != NO_TIME && nowNanos > lastPacketAtNanos) {
+ recordTickRate(nowNanos - lastPacketAtNanos);
+ }
+ lastClockAtNanos = nowNanos;
+ lastPacketAtNanos = Math.max(lastPacketAtNanos, nowNanos);
+ lastPacketPosition = evidence.playerPosition();
+ return LagPacketResult.RECORDED;
+ }
+
+ /** Observes current lag state and advances the 900 ms recent-lag evidence window. */
+ public LagSnapshot observe(LagStateEvidence evidence) {
+ Objects.requireNonNull(evidence, "evidence");
+ if (worldEpoch == NO_WORLD) {
+ return LagSnapshot.unavailable(evidence.worldEpoch(), LagSnapshot.Status.NOT_JOINED);
+ }
+ if (evidence.worldEpoch() != worldEpoch) {
+ return LagSnapshot.unavailable(
+ evidence.worldEpoch(), LagSnapshot.Status.STALE_IDENTITY);
+ }
+ if (evidence.connection().isUnknown()) {
+ return LagSnapshot.unavailable(
+ evidence.worldEpoch(), LagSnapshot.Status.CONNECTION_UNKNOWN);
+ }
+ if (evidence.connection().isAbsent()) {
+ return LagSnapshot.unavailable(
+ evidence.worldEpoch(), LagSnapshot.Status.CONNECTION_UNAVAILABLE);
+ }
+
+ long nowNanos = readClock();
+ if (nowNanos < lastClockAtNanos) {
+ return LagSnapshot.unavailable(
+ evidence.worldEpoch(), LagSnapshot.Status.CLOCK_REGRESSION);
+ }
+ lastClockAtNanos = nowNanos;
+
+ ServerResponsiveness responsiveness = serverTimeTracker.observe(nowNanos, true);
+ if (responsiveness == ServerResponsiveness.UNKNOWN) {
+ return LagSnapshot.unavailable(
+ evidence.worldEpoch(), LagSnapshot.Status.SERVER_UNKNOWN);
+ }
+ if (responsiveness == ServerResponsiveness.LAGGING) {
+ lastLagObservedAtNanos = nowNanos;
+ }
+
+ boolean recentlyLagging = lastLagObservedAtNanos != NO_TIME
+ && nowNanos - lastLagObservedAtNanos < RECENT_LAG_NANOS;
+ OptionalLong packetAge = lastPacketAtNanos == NO_TIME
+ ? OptionalLong.empty()
+ : OptionalLong.of(nowNanos - lastPacketAtNanos);
+ return LagSnapshot.current(
+ evidence.worldEpoch(),
+ responsiveness,
+ recentlyLagging,
+ lastPacketPosition,
+ packetAge,
+ averageTickRate());
+ }
+
+ /** Clears the active identity, heartbeat, position, TPS, and recent-lag evidence. */
+ public void reset() {
+ serverTimeTracker.reset();
+ worldEpoch = NO_WORLD;
+ lastClockAtNanos = NO_TIME;
+ lastPacketAtNanos = NO_TIME;
+ lastLagObservedAtNanos = NO_TIME;
+ lastPacketPosition = Observation.unknown();
+ tickRates.clear();
+ }
+
+ private void recordTickRate(long elapsedNanos) {
+ double tickRate = Math.min(
+ MAX_TICK_RATE,
+ MAX_TICK_RATE * NANOS_PER_SECOND / elapsedNanos);
+ if (tickRates.size() == TPS_HISTORY_SIZE) {
+ tickRates.removeFirst();
+ }
+ tickRates.addLast(tickRate);
+ }
+
+ private OptionalDouble averageTickRate() {
+ if (tickRates.isEmpty()) {
+ return OptionalDouble.empty();
+ }
+ double total = 0.0d;
+ for (double tickRate : tickRates) {
+ total += tickRate;
+ }
+ return OptionalDouble.of(total / tickRates.size());
+ }
+
+ private long readClock() {
+ long nowNanos = clock.nowNanos();
+ if (nowNanos < 0L) {
+ throw new IllegalStateException("clock returned negative monotonic time");
+ }
+ return nowNanos;
+ }
+
+ private static void requireWorldEpoch(long worldEpoch) {
+ if (worldEpoch < 0L) {
+ throw new IllegalArgumentException("worldEpoch must not be negative");
+ }
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketEvidence.java b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketEvidence.java
new file mode 100644
index 00000000..afc4d2c8
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketEvidence.java
@@ -0,0 +1,19 @@
+package dev.hylfrd.farmhelper.feature.lag;
+
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.PositionSnapshot;
+
+import java.util.Objects;
+
+/** Immutable client-thread evidence captured when a server time packet arrives. */
+public record LagPacketEvidence(
+ long worldEpoch,
+ Observation playerPosition
+) {
+ public LagPacketEvidence {
+ if (worldEpoch < 0L) {
+ throw new IllegalArgumentException("worldEpoch must not be negative");
+ }
+ Objects.requireNonNull(playerPosition, "playerPosition");
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketResult.java
new file mode 100644
index 00000000..f2556044
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketResult.java
@@ -0,0 +1,9 @@
+package dev.hylfrd.farmhelper.feature.lag;
+
+/** Result of attempting to record one server time packet. */
+public enum LagPacketResult {
+ RECORDED,
+ NOT_JOINED,
+ STALE_IDENTITY,
+ CLOCK_REGRESSION
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagSnapshot.java b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagSnapshot.java
new file mode 100644
index 00000000..6e043f62
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagSnapshot.java
@@ -0,0 +1,93 @@
+package dev.hylfrd.farmhelper.feature.lag;
+
+import dev.hylfrd.farmhelper.macro.ServerResponsiveness;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.PositionSnapshot;
+
+import java.util.Objects;
+import java.util.OptionalDouble;
+import java.util.OptionalLong;
+
+/** Immutable result of one lag observation. Durations are always monotonic nanoseconds. */
+public record LagSnapshot(
+ long worldEpoch,
+ Status status,
+ ServerResponsiveness responsiveness,
+ boolean recentlyLagging,
+ Observation lastPacketPosition,
+ OptionalLong lastPacketAgeNanos,
+ OptionalDouble tickRate
+) {
+ public enum Status {
+ CURRENT,
+ NOT_JOINED,
+ STALE_IDENTITY,
+ CONNECTION_UNKNOWN,
+ CONNECTION_UNAVAILABLE,
+ SERVER_UNKNOWN,
+ CLOCK_REGRESSION
+ }
+
+ public LagSnapshot {
+ if (worldEpoch < 0L) {
+ throw new IllegalArgumentException("worldEpoch must not be negative");
+ }
+ Objects.requireNonNull(status, "status");
+ Objects.requireNonNull(responsiveness, "responsiveness");
+ Objects.requireNonNull(lastPacketPosition, "lastPacketPosition");
+ Objects.requireNonNull(lastPacketAgeNanos, "lastPacketAgeNanos");
+ Objects.requireNonNull(tickRate, "tickRate");
+ if (lastPacketAgeNanos.isPresent() && lastPacketAgeNanos.getAsLong() < 0L) {
+ throw new IllegalArgumentException("lastPacketAgeNanos must not be negative");
+ }
+ if (tickRate.isPresent()) {
+ double value = tickRate.getAsDouble();
+ if (!Double.isFinite(value) || value <= 0.0d || value > LagDetector.MAX_TICK_RATE) {
+ throw new IllegalArgumentException("tickRate must be finite and in (0, 20]");
+ }
+ }
+ if (status == Status.CURRENT && responsiveness == ServerResponsiveness.UNKNOWN) {
+ throw new IllegalArgumentException("current lag evidence must have known responsiveness");
+ }
+ if (status != Status.CURRENT
+ && (responsiveness != ServerResponsiveness.UNKNOWN
+ || recentlyLagging
+ || !lastPacketPosition.isUnknown()
+ || lastPacketAgeNanos.isPresent()
+ || tickRate.isPresent())) {
+ throw new IllegalArgumentException("non-current lag evidence must be fully redacted");
+ }
+ }
+
+ static LagSnapshot current(
+ long worldEpoch,
+ ServerResponsiveness responsiveness,
+ boolean recentlyLagging,
+ Observation lastPacketPosition,
+ OptionalLong lastPacketAgeNanos,
+ OptionalDouble tickRate
+ ) {
+ return new LagSnapshot(
+ worldEpoch,
+ Status.CURRENT,
+ responsiveness,
+ recentlyLagging,
+ lastPacketPosition,
+ lastPacketAgeNanos,
+ tickRate);
+ }
+
+ static LagSnapshot unavailable(long worldEpoch, Status status) {
+ if (status == Status.CURRENT) {
+ throw new IllegalArgumentException("current status is not unavailable");
+ }
+ return new LagSnapshot(
+ worldEpoch,
+ status,
+ ServerResponsiveness.UNKNOWN,
+ false,
+ Observation.unknown(),
+ OptionalLong.empty(),
+ OptionalDouble.empty());
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagStateEvidence.java b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagStateEvidence.java
new file mode 100644
index 00000000..ea195660
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/lag/LagStateEvidence.java
@@ -0,0 +1,19 @@
+package dev.hylfrd.farmhelper.feature.lag;
+
+import dev.hylfrd.farmhelper.runtime.snapshot.ConnectionSnapshot;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+
+import java.util.Objects;
+
+/** Immutable connection and world identity evidence for one lag observation. */
+public record LagStateEvidence(
+ long worldEpoch,
+ Observation connection
+) {
+ public LagStateEvidence {
+ if (worldEpoch < 0L) {
+ throw new IllegalArgumentException("worldEpoch must not be negative");
+ }
+ Objects.requireNonNull(connection, "connection");
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/lag/package-info.java b/src/main/java/dev/hylfrd/farmhelper/feature/lag/package-info.java
new file mode 100644
index 00000000..dff3e29b
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/lag/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Client-thread, Minecraft-free server lag, recent-lag, packet-position, and TPS evidence.
+ */
+package dev.hylfrd.farmhelper.feature.lag;
diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/lag/LagDetectorTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/lag/LagDetectorTest.java
new file mode 100644
index 00000000..7d0a3d80
--- /dev/null
+++ b/src/test/java/dev/hylfrd/farmhelper/feature/lag/LagDetectorTest.java
@@ -0,0 +1,323 @@
+package dev.hylfrd.farmhelper.feature.lag;
+
+import dev.hylfrd.farmhelper.macro.ServerResponsiveness;
+import dev.hylfrd.farmhelper.macro.ServerTimeTracker;
+import dev.hylfrd.farmhelper.runtime.snapshot.ConnectionSnapshot;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.PositionSnapshot;
+import dev.hylfrd.farmhelper.runtime.time.MonotonicClock;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LagDetectorTest {
+ private static final long WORLD_EPOCH = 42L;
+ private static final PositionSnapshot FIRST_POSITION = new PositionSnapshot(1.25d, 70.0d, -4.5d);
+ private static final PositionSnapshot SECOND_POSITION = new PositionSnapshot(2.0d, 71.0d, -3.0d);
+
+ @Test
+ void prejoinAndUnavailableConnectionEvidenceAreExplicitlyUnknown() {
+ Harness harness = harness();
+
+ assertUnavailable(
+ harness.detector.observe(ready(WORLD_EPOCH)),
+ LagSnapshot.Status.NOT_JOINED);
+
+ harness.detector.joined(WORLD_EPOCH);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+
+ assertUnavailable(
+ harness.detector.observe(new LagStateEvidence(
+ WORLD_EPOCH, Observation.unknown())),
+ LagSnapshot.Status.CONNECTION_UNKNOWN);
+ assertUnavailable(
+ harness.detector.observe(new LagStateEvidence(
+ WORLD_EPOCH, Observation.absent())),
+ LagSnapshot.Status.CONNECTION_UNAVAILABLE);
+
+ LagSnapshot current = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(LagSnapshot.Status.CURRENT, current.status());
+ assertEquals(Observation.present(FIRST_POSITION), current.lastPacketPosition());
+
+ harness.tracker.reset();
+ assertUnavailable(
+ harness.detector.observe(ready(WORLD_EPOCH)),
+ LagSnapshot.Status.SERVER_UNKNOWN);
+ }
+
+ @Test
+ void joinGraceAndLagThresholdComeFromTheSharedTracker() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+
+ harness.clock.advance(ServerTimeTracker.JOIN_GRACE_NANOS - 1L);
+ assertEquals(
+ ServerResponsiveness.RESPONSIVE,
+ harness.detector.observe(ready(WORLD_EPOCH)).responsiveness());
+
+ harness.clock.advance(1L);
+ LagSnapshot noPacketAfterGrace = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(ServerResponsiveness.LAGGING, noPacketAfterGrace.responsiveness());
+ assertTrue(noPacketAfterGrace.recentlyLagging());
+
+ harness.detector.joined(WORLD_EPOCH);
+ harness.clock.advance(ServerTimeTracker.JOIN_GRACE_NANOS);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ harness.clock.advance(ServerTimeTracker.LAG_THRESHOLD_NANOS);
+ assertEquals(
+ ServerResponsiveness.RESPONSIVE,
+ harness.detector.observe(ready(WORLD_EPOCH)).responsiveness());
+
+ harness.clock.advance(1L);
+ LagSnapshot overThreshold = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(ServerResponsiveness.LAGGING, overThreshold.responsiveness());
+ assertTrue(overThreshold.recentlyLagging());
+ }
+
+ @Test
+ void recentLagExpiresStrictlyNineHundredMillisecondsAfterLastLagObservation() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+ harness.clock.advance(ServerTimeTracker.JOIN_GRACE_NANOS);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ harness.clock.advance(ServerTimeTracker.LAG_THRESHOLD_NANOS + 1L);
+
+ LagSnapshot lagging = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(ServerResponsiveness.LAGGING, lagging.responsiveness());
+ assertTrue(lagging.recentlyLagging());
+
+ assertEquals(
+ LagPacketResult.RECORDED,
+ harness.detector.recordTimePacket(
+ packet(WORLD_EPOCH, Observation.present(SECOND_POSITION))));
+ LagSnapshot recovered = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(ServerResponsiveness.RESPONSIVE, recovered.responsiveness());
+ assertTrue(recovered.recentlyLagging());
+
+ harness.clock.advance(LagDetector.RECENT_LAG_NANOS - 1L);
+ assertTrue(harness.detector.observe(ready(WORLD_EPOCH)).recentlyLagging());
+ harness.clock.advance(1L);
+ assertFalse(harness.detector.observe(ready(WORLD_EPOCH)).recentlyLagging());
+ }
+
+ @Test
+ void newestPacketReplacesPositionWithPresentUnknownOrAbsentEvidence() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ LagSnapshot present = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(Observation.present(FIRST_POSITION), present.lastPacketPosition());
+ assertEquals(0L, present.lastPacketAgeNanos().orElseThrow());
+
+ harness.clock.advance(1L);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.unknown()));
+ LagSnapshot unknown = harness.detector.observe(ready(WORLD_EPOCH));
+ assertTrue(unknown.lastPacketPosition().isUnknown());
+ assertEquals(0L, unknown.lastPacketAgeNanos().orElseThrow());
+
+ harness.clock.advance(1L);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.absent()));
+ LagSnapshot absent = harness.detector.observe(ready(WORLD_EPOCH));
+ assertTrue(absent.lastPacketPosition().isAbsent());
+ assertEquals(ServerResponsiveness.RESPONSIVE, absent.responsiveness());
+ }
+
+ @Test
+ void staleWorldEvidenceIsRedactedAndMutatesNothing() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ harness.clock.advance(TimeUnit.SECONDS.toNanos(1L));
+
+ assertEquals(
+ LagPacketResult.STALE_IDENTITY,
+ harness.detector.recordTimePacket(
+ packet(WORLD_EPOCH - 1L, Observation.present(SECOND_POSITION))));
+ assertUnavailable(
+ harness.detector.observe(ready(WORLD_EPOCH - 1L)),
+ LagSnapshot.Status.STALE_IDENTITY);
+
+ LagSnapshot current = harness.detector.observe(ready(WORLD_EPOCH));
+ assertEquals(Observation.present(FIRST_POSITION), current.lastPacketPosition());
+ assertEquals(TimeUnit.SECONDS.toNanos(1L), current.lastPacketAgeNanos().orElseThrow());
+ assertTrue(current.tickRate().isEmpty());
+ }
+
+ @Test
+ void joiningAnotherWorldClearsPositionHistoryAndRecentLagEvidence() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+ harness.clock.advance(ServerTimeTracker.JOIN_GRACE_NANOS);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ harness.clock.advance(ServerTimeTracker.LAG_THRESHOLD_NANOS + 1L);
+ assertTrue(harness.detector.observe(ready(WORLD_EPOCH)).recentlyLagging());
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(SECOND_POSITION)));
+
+ long nextWorldEpoch = WORLD_EPOCH + 1L;
+ harness.detector.joined(nextWorldEpoch);
+ LagSnapshot nextWorld = harness.detector.observe(ready(nextWorldEpoch));
+ assertEquals(ServerResponsiveness.RESPONSIVE, nextWorld.responsiveness());
+ assertFalse(nextWorld.recentlyLagging());
+ assertTrue(nextWorld.lastPacketPosition().isUnknown());
+ assertTrue(nextWorld.lastPacketAgeNanos().isEmpty());
+ assertTrue(nextWorld.tickRate().isEmpty());
+ assertEquals(
+ LagPacketResult.STALE_IDENTITY,
+ harness.detector.recordTimePacket(
+ packet(WORLD_EPOCH, Observation.present(FIRST_POSITION))));
+ }
+
+ @Test
+ void tpsHistoryOmitsFirstAndDuplicateIntervalsAndKeepsNewestTwentySamples() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ assertTrue(harness.detector.observe(ready(WORLD_EPOCH)).tickRate().isEmpty());
+
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(SECOND_POSITION)));
+ assertTrue(harness.detector.observe(ready(WORLD_EPOCH)).tickRate().isEmpty());
+
+ harness.clock.advance(TimeUnit.SECONDS.toNanos(2L));
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ assertEquals(
+ 10.0d,
+ harness.detector.observe(ready(WORLD_EPOCH)).tickRate().orElseThrow(),
+ 0.000_001d);
+
+ for (int sample = 0; sample < LagDetector.TPS_HISTORY_SIZE; sample++) {
+ harness.clock.advance(TimeUnit.SECONDS.toNanos(1L));
+ harness.detector.recordTimePacket(
+ packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ }
+ assertEquals(
+ 20.0d,
+ harness.detector.observe(ready(WORLD_EPOCH)).tickRate().orElseThrow(),
+ 0.000_001d);
+
+ harness.detector.joined(WORLD_EPOCH);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ harness.clock.advance(TimeUnit.MILLISECONDS.toNanos(100L));
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ assertEquals(
+ LagDetector.MAX_TICK_RATE,
+ harness.detector.observe(ready(WORLD_EPOCH)).tickRate().orElseThrow(),
+ 0.000_001d);
+ }
+
+ @Test
+ void clockRegressionIsExplicitAndDoesNotReplacePacketEvidence() {
+ MutableClock clock = new MutableClock(100L);
+ ServerTimeTracker tracker = new ServerTimeTracker();
+ LagDetector detector = new LagDetector(clock, tracker);
+ detector.joined(WORLD_EPOCH);
+ clock.set(200L);
+ detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+
+ clock.set(150L);
+ assertEquals(
+ LagPacketResult.CLOCK_REGRESSION,
+ detector.recordTimePacket(
+ packet(WORLD_EPOCH, Observation.present(SECOND_POSITION))));
+ assertUnavailable(
+ detector.observe(ready(WORLD_EPOCH)),
+ LagSnapshot.Status.CLOCK_REGRESSION);
+
+ clock.set(200L);
+ LagSnapshot recovered = detector.observe(ready(WORLD_EPOCH));
+ assertEquals(Observation.present(FIRST_POSITION), recovered.lastPacketPosition());
+ assertEquals(0L, recovered.lastPacketAgeNanos().orElseThrow());
+ }
+
+ @Test
+ void resetAndInvalidInputsCannotLeakOrFabricateState() {
+ Harness harness = harness();
+ harness.detector.joined(WORLD_EPOCH);
+ harness.detector.recordTimePacket(packet(WORLD_EPOCH, Observation.present(FIRST_POSITION)));
+ harness.detector.reset();
+ assertUnavailable(
+ harness.detector.observe(ready(WORLD_EPOCH)),
+ LagSnapshot.Status.NOT_JOINED);
+ assertEquals(ServerResponsiveness.UNKNOWN, harness.tracker.observe(0L, true));
+
+ assertThrows(NullPointerException.class, () -> new LagDetector(null, harness.tracker));
+ assertThrows(NullPointerException.class, () -> new LagDetector(harness.clock, null));
+ assertThrows(IllegalArgumentException.class, () -> harness.detector.joined(-1L));
+ assertThrows(IllegalArgumentException.class,
+ () -> new LagPacketEvidence(-1L, Observation.unknown()));
+ assertThrows(IllegalArgumentException.class,
+ () -> new LagStateEvidence(-1L, Observation.unknown()));
+ assertThrows(NullPointerException.class,
+ () -> new LagPacketEvidence(WORLD_EPOCH, null));
+ assertThrows(NullPointerException.class,
+ () -> new LagStateEvidence(WORLD_EPOCH, null));
+
+ MutableClock negativeClock = new MutableClock(-1L);
+ LagDetector negative = new LagDetector(negativeClock, new ServerTimeTracker());
+ assertThrows(IllegalStateException.class, () -> negative.joined(WORLD_EPOCH));
+ }
+
+ private static Harness harness() {
+ MutableClock clock = new MutableClock(0L);
+ ServerTimeTracker tracker = new ServerTimeTracker();
+ return new Harness(clock, tracker, new LagDetector(clock, tracker));
+ }
+
+ private static LagPacketEvidence packet(
+ long worldEpoch,
+ Observation position
+ ) {
+ return new LagPacketEvidence(worldEpoch, position);
+ }
+
+ private static LagStateEvidence ready(long worldEpoch) {
+ return new LagStateEvidence(
+ worldEpoch,
+ Observation.present(ConnectionSnapshot.multiplayer()));
+ }
+
+ private static void assertUnavailable(LagSnapshot snapshot, LagSnapshot.Status status) {
+ assertEquals(status, snapshot.status());
+ assertEquals(ServerResponsiveness.UNKNOWN, snapshot.responsiveness());
+ assertFalse(snapshot.recentlyLagging());
+ assertTrue(snapshot.lastPacketPosition().isUnknown());
+ assertTrue(snapshot.lastPacketAgeNanos().isEmpty());
+ assertTrue(snapshot.tickRate().isEmpty());
+ }
+
+ private record Harness(
+ MutableClock clock,
+ ServerTimeTracker tracker,
+ LagDetector detector
+ ) {
+ }
+
+ private static final class MutableClock implements MonotonicClock {
+ private long nowNanos;
+
+ private MutableClock(long nowNanos) {
+ this.nowNanos = nowNanos;
+ }
+
+ @Override
+ public long nowNanos() {
+ return nowNanos;
+ }
+
+ private void advance(long deltaNanos) {
+ if (deltaNanos < 0L) {
+ throw new IllegalArgumentException("deltaNanos must not be negative");
+ }
+ nowNanos = Math.addExact(nowNanos, deltaNanos);
+ }
+
+ private void set(long nowNanos) {
+ this.nowNanos = nowNanos;
+ }
+ }
+}