Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/main/java/dev/hylfrd/farmhelper/feature/leave/LeaveTimer.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.hylfrd.farmhelper.feature.leave;

/**
* Narrow action boundary for the two ordered side effects of leave-timer expiry.
*
* <p>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.</p>
*/
public interface LeaveTimerDisconnectPort {
void stopMacro(long expectedMacroGeneration);

void disconnect(String reason);
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Client-thread, Minecraft-free leave countdown and disconnect policy. */
package dev.hylfrd.farmhelper.feature.leave;
Loading
Loading