diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimer.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimer.java new file mode 100644 index 00000000..8bf5d686 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimer.java @@ -0,0 +1,140 @@ +package dev.hylfrd.farmhelper.feature.leave; + +import dev.hylfrd.farmhelper.runtime.time.MonotonicClock; +import dev.hylfrd.farmhelper.runtime.time.PausableTimer; + +import java.util.Objects; +import java.util.OptionalLong; +import java.util.concurrent.TimeUnit; + +/** + * Client-thread, Minecraft-free countdown and delayed-disconnect state machine. + * + *

The countdown keeps aging while a failsafe or another feature is active, matching upstream's + * tick gates. Once those gates clear, an elapsed timer stops the exact macro generation and starts + * the fixed disconnect grace period. Explicit {@link #stop()} and {@link #start(long)} calls cancel + * any prior phase, including a pending disconnect.

+ */ +public final class LeaveTimer { + public static final long DISCONNECT_DELAY_NANOS = TimeUnit.MILLISECONDS.toNanos(500L); + public static final String DISCONNECT_REASON = "The timer has ended"; + + private final MonotonicClock clock; + private final LeaveTimerStatusSource statusSource; + private final LeaveTimerDisconnectPort disconnectPort; + + private LeaveTimerState state = LeaveTimerState.STOPPED; + private long macroGeneration; + private PausableTimer countdown; + private PausableTimer disconnectDelay; + + public LeaveTimer( + MonotonicClock clock, + LeaveTimerStatusSource statusSource, + LeaveTimerDisconnectPort disconnectPort + ) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.statusSource = Objects.requireNonNull(statusSource, "statusSource"); + this.disconnectPort = Objects.requireNonNull(disconnectPort, "disconnectPort"); + } + + public LeaveTimerState state() { + return state; + } + + public long macroGeneration() { + return macroGeneration; + } + + /** Returns the remaining time in the current timed phase, if there is one. */ + public OptionalLong remainingNanos() { + return switch (state) { + case COUNTING_DOWN -> OptionalLong.of(countdown.remainingNanos()); + case DISCONNECT_PENDING -> OptionalLong.of(disconnectDelay.remainingNanos()); + case STOPPED, COMPLETE -> OptionalLong.empty(); + }; + } + + /** Starts or restarts the countdown for the status source's exact active macro generation. */ + public void start(long durationNanos) { + if (durationNanos < 0L) { + throw new IllegalArgumentException("durationNanos must not be negative"); + } + LeaveTimerStatus status = currentStatus(); + if (!status.macroActive()) { + throw new IllegalStateException("cannot start a leave timer without an active macro"); + } + + PausableTimer nextCountdown = new PausableTimer(clock, durationNanos); + macroGeneration = status.macroGeneration(); + countdown = nextCountdown; + disconnectDelay = null; + state = LeaveTimerState.COUNTING_DOWN; + } + + /** Cancels the countdown or delayed disconnect without invoking either action-port method. */ + public void stop() { + clear(LeaveTimerState.STOPPED); + } + + /** Advances one state-machine step from the injected status and monotonic clock. */ + public LeaveTimerResult tick() { + return switch (state) { + case STOPPED -> LeaveTimerResult.STOPPED; + case COUNTING_DOWN -> tickCountdown(); + case DISCONNECT_PENDING -> tickDisconnectDelay(); + case COMPLETE -> LeaveTimerResult.COMPLETE; + }; + } + + private LeaveTimerResult tickCountdown() { + LeaveTimerStatus status = currentStatus(); + if (!status.macroActive()) { + clear(LeaveTimerState.STOPPED); + return LeaveTimerResult.MACRO_INACTIVE; + } + if (status.macroGeneration() != macroGeneration) { + clear(LeaveTimerState.STOPPED); + return LeaveTimerResult.STALE_MACRO; + } + if (status.failsafeActive()) { + return LeaveTimerResult.FAILSAFE_ACTIVE; + } + if (status.otherFeatureActive()) { + return LeaveTimerResult.OTHER_FEATURE_ACTIVE; + } + if (!countdown.elapsed()) { + return LeaveTimerResult.COUNTING_DOWN; + } + + PausableTimer nextDisconnectDelay = + new PausableTimer(clock, DISCONNECT_DELAY_NANOS); + disconnectPort.stopMacro(macroGeneration); + countdown = null; + disconnectDelay = nextDisconnectDelay; + state = LeaveTimerState.DISCONNECT_PENDING; + return LeaveTimerResult.MACRO_STOP_REQUESTED; + } + + private LeaveTimerResult tickDisconnectDelay() { + if (!disconnectDelay.elapsed()) { + return LeaveTimerResult.DISCONNECT_PENDING; + } + + disconnectPort.disconnect(DISCONNECT_REASON); + disconnectDelay = null; + state = LeaveTimerState.COMPLETE; + return LeaveTimerResult.DISCONNECT_REQUESTED; + } + + private LeaveTimerStatus currentStatus() { + return Objects.requireNonNull(statusSource.currentStatus(), "statusSource.currentStatus()"); + } + + private void clear(LeaveTimerState nextState) { + state = nextState; + macroGeneration = 0L; + countdown = null; + disconnectDelay = null; + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerDisconnectPort.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerDisconnectPort.java new file mode 100644 index 00000000..7b0d5b40 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerDisconnectPort.java @@ -0,0 +1,14 @@ +package dev.hylfrd.farmhelper.feature.leave; + +/** + * Narrow action boundary for the two ordered side effects of leave-timer expiry. + * + *

The macro stop implementation must generation-fence the request. Implementations should make + * both operations safe to retry when they throw, because the timer advances only after a call + * returns successfully.

+ */ +public interface LeaveTimerDisconnectPort { + void stopMacro(long expectedMacroGeneration); + + void disconnect(String reason); +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerResult.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerResult.java new file mode 100644 index 00000000..53fd7b58 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerResult.java @@ -0,0 +1,15 @@ +package dev.hylfrd.farmhelper.feature.leave; + +/** Observable result of one explicit leave-timer tick. */ +public enum LeaveTimerResult { + STOPPED, + COUNTING_DOWN, + FAILSAFE_ACTIVE, + OTHER_FEATURE_ACTIVE, + MACRO_INACTIVE, + STALE_MACRO, + MACRO_STOP_REQUESTED, + DISCONNECT_PENDING, + DISCONNECT_REQUESTED, + COMPLETE +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerState.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerState.java new file mode 100644 index 00000000..3da80624 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerState.java @@ -0,0 +1,9 @@ +package dev.hylfrd.farmhelper.feature.leave; + +/** Durable leave-timer phases; no phase owns a client or network object. */ +public enum LeaveTimerState { + STOPPED, + COUNTING_DOWN, + DISCONNECT_PENDING, + COMPLETE +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerStatus.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerStatus.java new file mode 100644 index 00000000..836fac7a --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerStatus.java @@ -0,0 +1,31 @@ +package dev.hylfrd.farmhelper.feature.leave; + +/** One coherent macro, failsafe, and feature-lifecycle observation for a leave-timer tick. */ +public record LeaveTimerStatus( + boolean macroActive, + long macroGeneration, + boolean failsafeActive, + boolean otherFeatureActive +) { + public LeaveTimerStatus { + if (macroGeneration < 0L) { + throw new IllegalArgumentException("macroGeneration must not be negative"); + } + if (macroActive && macroGeneration == 0L) { + throw new IllegalArgumentException("an active macro must have a positive generation"); + } + } + + public static LeaveTimerStatus active( + long macroGeneration, + boolean failsafeActive, + boolean otherFeatureActive + ) { + return new LeaveTimerStatus( + true, macroGeneration, failsafeActive, otherFeatureActive); + } + + public static LeaveTimerStatus inactive() { + return new LeaveTimerStatus(false, 0L, false, false); + } +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerStatusSource.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerStatusSource.java new file mode 100644 index 00000000..8faa7a4e --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerStatusSource.java @@ -0,0 +1,7 @@ +package dev.hylfrd.farmhelper.feature.leave; + +/** Supplies one client-thread status snapshot without exposing runtime or platform ownership. */ +@FunctionalInterface +public interface LeaveTimerStatusSource { + LeaveTimerStatus currentStatus(); +} diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/leave/package-info.java b/src/main/java/dev/hylfrd/farmhelper/feature/leave/package-info.java new file mode 100644 index 00000000..c9125717 --- /dev/null +++ b/src/main/java/dev/hylfrd/farmhelper/feature/leave/package-info.java @@ -0,0 +1,2 @@ +/** Client-thread, Minecraft-free leave countdown and disconnect policy. */ +package dev.hylfrd.farmhelper.feature.leave; diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerTest.java new file mode 100644 index 00000000..677d60d8 --- /dev/null +++ b/src/test/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimerTest.java @@ -0,0 +1,274 @@ +package dev.hylfrd.farmhelper.feature.leave; + +import dev.hylfrd.farmhelper.runtime.time.MonotonicClock; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LeaveTimerTest { + private static final long GENERATION = 7L; + + @Test + void exactBoundaryStopsMacroThenDisconnectsOnceAfterFiveHundredMilliseconds() { + Harness harness = harness(); + long duration = TimeUnit.MINUTES.toNanos(60L); + harness.timer.start(duration); + + assertEquals(LeaveTimerState.COUNTING_DOWN, harness.timer.state()); + assertEquals(GENERATION, harness.timer.macroGeneration()); + assertEquals(duration, harness.timer.remainingNanos().orElseThrow()); + + harness.clock.advance(duration - 1L); + assertEquals(LeaveTimerResult.COUNTING_DOWN, harness.timer.tick()); + assertTrue(harness.port.actions.isEmpty()); + + harness.clock.advance(1L); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, harness.timer.tick()); + assertEquals(LeaveTimerState.DISCONNECT_PENDING, harness.timer.state()); + assertEquals(List.of("stop:" + GENERATION), harness.port.actions); + assertEquals(LeaveTimer.DISCONNECT_DELAY_NANOS, + harness.timer.remainingNanos().orElseThrow()); + + harness.status.current = LeaveTimerStatus.inactive(); + harness.clock.advance(LeaveTimer.DISCONNECT_DELAY_NANOS - 1L); + assertEquals(LeaveTimerResult.DISCONNECT_PENDING, harness.timer.tick()); + harness.clock.advance(1L); + assertEquals(LeaveTimerResult.DISCONNECT_REQUESTED, harness.timer.tick()); + assertEquals(LeaveTimerState.COMPLETE, harness.timer.state()); + assertTrue(harness.timer.remainingNanos().isEmpty()); + assertEquals(List.of( + "stop:" + GENERATION, + "disconnect:" + LeaveTimer.DISCONNECT_REASON), harness.port.actions); + + assertEquals(LeaveTimerResult.COMPLETE, harness.timer.tick()); + assertEquals(2, harness.port.actions.size()); + } + + @Test + void failsafeAndOtherFeatureDeferExpiryWithoutPausingCountdown() { + Harness harness = harness(); + harness.timer.start(10L); + harness.clock.advance(10L); + + harness.status.current = LeaveTimerStatus.active(GENERATION, true, true); + assertEquals(LeaveTimerResult.FAILSAFE_ACTIVE, harness.timer.tick()); + harness.clock.advance(90L); + + harness.status.current = LeaveTimerStatus.active(GENERATION, false, true); + assertEquals(LeaveTimerResult.OTHER_FEATURE_ACTIVE, harness.timer.tick()); + harness.status.current = LeaveTimerStatus.active(GENERATION, false, false); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, harness.timer.tick()); + assertEquals(List.of("stop:" + GENERATION), harness.port.actions); + assertEquals(LeaveTimer.DISCONNECT_DELAY_NANOS, + harness.timer.remainingNanos().orElseThrow()); + } + + @Test + void inactiveOrReplacementMacroCancelsOnlyTheOwnedCountdown() { + Harness inactive = harness(); + inactive.timer.start(10L); + inactive.status.current = LeaveTimerStatus.inactive(); + assertEquals(LeaveTimerResult.MACRO_INACTIVE, inactive.timer.tick()); + assertEquals(LeaveTimerState.STOPPED, inactive.timer.state()); + assertTrue(inactive.port.actions.isEmpty()); + + Harness replacement = harness(); + replacement.timer.start(10L); + replacement.status.current = LeaveTimerStatus.active(GENERATION + 1L, false, false); + assertEquals(LeaveTimerResult.STALE_MACRO, replacement.timer.tick()); + assertEquals(LeaveTimerState.STOPPED, replacement.timer.state()); + assertTrue(replacement.port.actions.isEmpty()); + } + + @Test + void explicitStopAndRestartCancelCountdownOrPendingDisconnect() { + Harness harness = harness(); + harness.timer.start(10L); + harness.clock.advance(5L); + harness.timer.stop(); + harness.clock.advance(100L); + assertEquals(LeaveTimerResult.STOPPED, harness.timer.tick()); + assertTrue(harness.port.actions.isEmpty()); + + harness.timer.start(2L); + harness.clock.advance(2L); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, harness.timer.tick()); + harness.timer.stop(); + harness.clock.advance(LeaveTimer.DISCONNECT_DELAY_NANOS); + assertEquals(LeaveTimerResult.STOPPED, harness.timer.tick()); + assertEquals(List.of("stop:" + GENERATION), harness.port.actions); + + harness.status.current = LeaveTimerStatus.active(GENERATION + 1L, false, false); + harness.timer.start(20L); + harness.clock.advance(19L); + assertEquals(LeaveTimerResult.COUNTING_DOWN, harness.timer.tick()); + harness.timer.start(30L); + harness.clock.advance(29L); + assertEquals(LeaveTimerResult.COUNTING_DOWN, harness.timer.tick()); + harness.clock.advance(1L); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, harness.timer.tick()); + assertEquals(List.of("stop:" + GENERATION, "stop:" + (GENERATION + 1L)), + harness.port.actions); + } + + @Test + void zeroDurationNeedsAnExplicitTickAndClockRollbackCannotAdvanceTime() { + Harness immediate = harness(); + immediate.timer.start(0L); + assertTrue(immediate.port.actions.isEmpty()); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, immediate.timer.tick()); + + MutableClock clock = new MutableClock(1_000L); + MutableStatus status = new MutableStatus( + LeaveTimerStatus.active(GENERATION, false, false)); + RecordingPort port = new RecordingPort(); + LeaveTimer timer = new LeaveTimer(clock, status, port); + timer.start(100L); + + clock.set(900L); + assertEquals(LeaveTimerResult.COUNTING_DOWN, timer.tick()); + assertEquals(100L, timer.remainingNanos().orElseThrow()); + clock.set(1_099L); + assertEquals(LeaveTimerResult.COUNTING_DOWN, timer.tick()); + clock.set(1_100L); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, timer.tick()); + } + + @Test + void invalidConstructionStatusAndDurationAreRejectedWithoutReplacingAValidRun() { + MutableClock clock = new MutableClock(); + MutableStatus status = new MutableStatus( + LeaveTimerStatus.active(GENERATION, false, false)); + RecordingPort port = new RecordingPort(); + + assertThrows(NullPointerException.class, () -> new LeaveTimer(null, status, port)); + assertThrows(NullPointerException.class, () -> new LeaveTimer(clock, null, port)); + assertThrows(NullPointerException.class, () -> new LeaveTimer(clock, status, null)); + assertThrows(IllegalArgumentException.class, + () -> new LeaveTimerStatus(true, 0L, false, false)); + assertThrows(IllegalArgumentException.class, + () -> new LeaveTimerStatus(false, -1L, false, false)); + + LeaveTimer timer = new LeaveTimer(clock, status, port); + timer.start(10L); + assertThrows(IllegalArgumentException.class, () -> timer.start(-1L)); + assertEquals(LeaveTimerState.COUNTING_DOWN, timer.state()); + assertEquals(10L, timer.remainingNanos().orElseThrow()); + + status.current = LeaveTimerStatus.inactive(); + assertThrows(IllegalStateException.class, () -> timer.start(1L)); + assertEquals(LeaveTimerState.COUNTING_DOWN, timer.state()); + + status.current = null; + assertThrows(NullPointerException.class, () -> timer.start(1L)); + assertEquals(LeaveTimerState.COUNTING_DOWN, timer.state()); + } + + @Test + void throwingActionsLeaveTheirPhaseRetryable() { + Harness harness = harness(); + harness.timer.start(0L); + harness.port.stopFailures = 1; + + assertThrows(IllegalStateException.class, harness.timer::tick); + assertEquals(LeaveTimerState.COUNTING_DOWN, harness.timer.state()); + assertEquals(1, harness.port.stopCalls); + assertEquals(LeaveTimerResult.MACRO_STOP_REQUESTED, harness.timer.tick()); + assertEquals(2, harness.port.stopCalls); + + harness.clock.advance(LeaveTimer.DISCONNECT_DELAY_NANOS); + harness.port.disconnectFailures = 1; + assertThrows(IllegalStateException.class, harness.timer::tick); + assertEquals(LeaveTimerState.DISCONNECT_PENDING, harness.timer.state()); + assertEquals(1, harness.port.disconnectCalls); + assertEquals(LeaveTimerResult.DISCONNECT_REQUESTED, harness.timer.tick()); + assertEquals(2, harness.port.disconnectCalls); + assertEquals(LeaveTimerState.COMPLETE, harness.timer.state()); + } + + private static Harness harness() { + MutableClock clock = new MutableClock(); + MutableStatus status = new MutableStatus( + LeaveTimerStatus.active(GENERATION, false, false)); + RecordingPort port = new RecordingPort(); + return new Harness(clock, status, port, new LeaveTimer(clock, status, port)); + } + + private record Harness( + MutableClock clock, + MutableStatus status, + RecordingPort port, + LeaveTimer timer + ) { + } + + private static final class MutableStatus implements LeaveTimerStatusSource { + private LeaveTimerStatus current; + + private MutableStatus(LeaveTimerStatus current) { + this.current = current; + } + + @Override + public LeaveTimerStatus currentStatus() { + return current; + } + } + + private static final class RecordingPort implements LeaveTimerDisconnectPort { + private final List actions = new ArrayList<>(); + private int stopCalls; + private int disconnectCalls; + private int stopFailures; + private int disconnectFailures; + + @Override + public void stopMacro(long expectedMacroGeneration) { + stopCalls++; + if (stopFailures-- > 0) { + throw new IllegalStateException("stop failed"); + } + actions.add("stop:" + expectedMacroGeneration); + } + + @Override + public void disconnect(String reason) { + disconnectCalls++; + if (disconnectFailures-- > 0) { + throw new IllegalStateException("disconnect failed"); + } + actions.add("disconnect:" + reason); + } + } + + private static final class MutableClock implements MonotonicClock { + private long now; + + private MutableClock() { + this(0L); + } + + private MutableClock(long now) { + this.now = now; + } + + @Override + public long nowNanos() { + return now; + } + + private void advance(long nanos) { + now = Math.addExact(now, nanos); + } + + private void set(long now) { + this.now = now; + } + } +}