From 139a7751a05c110212ddee1603a58b4b16ac8cfb Mon Sep 17 00:00:00 2001 From: Hylfrd Date: Mon, 31 Aug 2026 11:32:19 +0800 Subject: [PATCH] feat(lag): recover immutable lag evidence detector --- .../farmhelper/feature/lag/LagDetector.java | 183 ++++++++++ .../feature/lag/LagPacketEvidence.java | 19 ++ .../feature/lag/LagPacketResult.java | 9 + .../farmhelper/feature/lag/LagSnapshot.java | 93 +++++ .../feature/lag/LagStateEvidence.java | 19 ++ .../farmhelper/feature/lag/package-info.java | 4 + .../feature/lag/LagDetectorTest.java | 323 ++++++++++++++++++ 7 files changed, 650 insertions(+) create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lag/LagDetector.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketEvidence.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lag/LagPacketResult.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lag/LagSnapshot.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lag/LagStateEvidence.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/lag/package-info.java create mode 100644 src/test/java/dev/hylfrd/farmhelper/feature/lag/LagDetectorTest.java 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; + } + } +}