diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectController.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectController.java
new file mode 100644
index 00000000..8aea95ed
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectController.java
@@ -0,0 +1,476 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+import dev.hylfrd.farmhelper.runtime.gamestate.GameStateSnapshot;
+import dev.hylfrd.farmhelper.runtime.gamestate.SemanticLocation;
+import dev.hylfrd.farmhelper.runtime.snapshot.ConnectionSnapshot;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.ScreenSnapshot;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.function.BooleanSupplier;
+
+/**
+ * Minecraft-free reconnect policy derived from AutoReconnect lines 43-237 at pinned upstream
+ * commit {@code eacb323fbde3eff94d4f2ee7baacb059d84b8e3a}.
+ *
+ *
The controller owns no Macro, Failsafe, Feature, Scheduler, or mouse lifecycle. Those
+ * composition concerns are intentionally deferred. Every client mutation is expressed through a
+ * narrow port, and routing waits for a game-state generation newer than the preceding action.
+ */
+public final class AutoReconnectController {
+ public static final String DISCONNECTED_SCREEN_TYPE =
+ "net.minecraft.client.gui.screens.DisconnectedScreen";
+
+ private final ReconnectPolicy policy;
+ private final ReconnectConnectionPort connectionPort;
+ private final ReconnectTravelPort travelPort;
+ private final ReconnectScreenPort screenPort;
+
+ private ReconnectState state = ReconnectState.STOPPED;
+ private long dueAtMillis = -1L;
+ private long deadlineAtMillis = -1L;
+ private long gameStateGenerationFloor = -1L;
+ private long lastNowMillis = -1L;
+ private int connectionAttempts;
+ private boolean awaitingInitialDisconnect;
+ private Throwable lastPortFailure;
+
+ public AutoReconnectController(
+ ReconnectPolicy policy,
+ ReconnectConnectionPort connectionPort,
+ ReconnectTravelPort travelPort,
+ ReconnectScreenPort screenPort
+ ) {
+ this.policy = Objects.requireNonNull(policy, "policy");
+ this.connectionPort = Objects.requireNonNull(connectionPort, "connectionPort");
+ this.travelPort = Objects.requireNonNull(travelPort, "travelPort");
+ this.screenPort = Objects.requireNonNull(screenPort, "screenPort");
+ }
+
+ public synchronized ReconnectState state() {
+ return state;
+ }
+
+ public synchronized boolean active() {
+ return state != ReconnectState.STOPPED;
+ }
+
+ public synchronized OptionalLong dueAtMillis() {
+ return active() ? OptionalLong.of(dueAtMillis) : OptionalLong.empty();
+ }
+
+ public synchronized OptionalLong deadlineAtMillis() {
+ return active() ? OptionalLong.of(deadlineAtMillis) : OptionalLong.empty();
+ }
+
+ public synchronized int connectionAttempts() {
+ return connectionAttempts;
+ }
+
+ /** The adapter failure that terminated the latest run, if one occurred. */
+ public synchronized Optional lastPortFailure() {
+ return Optional.ofNullable(lastPortFailure);
+ }
+
+ /**
+ * Starts a bounded run. An active multiplayer connection is first asked to disconnect;
+ * an absent connection proceeds directly to the configured initial delay.
+ */
+ public synchronized ReconnectDecision start(
+ ReconnectSnapshot snapshot,
+ long initialDelayMillis
+ ) {
+ Objects.requireNonNull(snapshot, "snapshot");
+ if (initialDelayMillis < 0L) {
+ throw new IllegalArgumentException("initialDelayMillis must be non-negative");
+ }
+ observeNow(snapshot.nowMillis());
+ if (active()) {
+ return running(ReconnectReason.ALREADY_RUNNING);
+ }
+
+ lastPortFailure = null;
+ Observation connection = snapshot.connection();
+ if (connection.isUnknown()) {
+ return inactive(ReconnectOutcome.REJECTED, ReconnectReason.CONNECTION_UNKNOWN);
+ }
+ if (isSingleplayer(connection)) {
+ return inactive(ReconnectOutcome.REJECTED,
+ ReconnectReason.SINGLEPLAYER_UNSUPPORTED);
+ }
+
+ state = ReconnectState.CONNECTING;
+ dueAtMillis = saturatedAdd(snapshot.nowMillis(), initialDelayMillis);
+ deadlineAtMillis = saturatedAdd(snapshot.nowMillis(), policy.timeoutMillis());
+ gameStateGenerationFloor = snapshot.gameState().generation();
+ connectionAttempts = 0;
+ awaitingInitialDisconnect = connection.isPresent();
+
+ if (!awaitingInitialDisconnect) {
+ return running(ReconnectReason.STARTED);
+ }
+
+ PortInvocation invocation = invoke(connectionPort::disconnect);
+ if (invocation.failed()) {
+ return finish(ReconnectOutcome.FAILED, ReconnectReason.PORT_FAILURE,
+ ReconnectAction.DISCONNECT, ReconnectActionStatus.FAILED);
+ }
+ return running(
+ ReconnectReason.STARTED,
+ ReconnectAction.DISCONNECT,
+ invocation.status());
+ }
+
+ /** Advances one client-thread observation without owning a scheduler or a clock. */
+ public synchronized ReconnectDecision tick(ReconnectSnapshot snapshot) {
+ Objects.requireNonNull(snapshot, "snapshot");
+ observeNow(snapshot.nowMillis());
+ if (!active()) {
+ return inactive(ReconnectOutcome.IDLE, ReconnectReason.IDLE);
+ }
+ if (snapshot.nowMillis() >= deadlineAtMillis) {
+ return finish(ReconnectOutcome.TIMED_OUT, ReconnectReason.DEADLINE_REACHED,
+ ReconnectAction.NONE, ReconnectActionStatus.NOT_REQUESTED);
+ }
+ return switch (state) {
+ case STOPPED -> throw new IllegalStateException("active controller is stopped");
+ case CONNECTING -> tickConnecting(snapshot);
+ case LOBBY, GARDEN -> tickTravel(snapshot);
+ };
+ }
+
+ /** Stops the active run without invoking a platform port; repeated stops are idempotent. */
+ public synchronized ReconnectDecision stop() {
+ if (!active()) {
+ return inactive(ReconnectOutcome.IDLE, ReconnectReason.IDLE);
+ }
+ return finish(ReconnectOutcome.CANCELLED, ReconnectReason.STOP_REQUESTED,
+ ReconnectAction.NONE, ReconnectActionStatus.NOT_REQUESTED);
+ }
+
+ /**
+ * Cancels only from the exact modern disconnected screen. Automation stops even when showing
+ * the title screen is rejected or throws, so a failed UI action cannot keep reconnecting.
+ */
+ public synchronized ReconnectDecision cancelFromDisconnectedScreen(
+ ReconnectSnapshot snapshot
+ ) {
+ Objects.requireNonNull(snapshot, "snapshot");
+ observeNow(snapshot.nowMillis());
+ if (!active()) {
+ return inactive(ReconnectOutcome.IDLE, ReconnectReason.IDLE);
+ }
+ if (!isDisconnectedScreen(snapshot.screen())) {
+ return running(ReconnectReason.CANCEL_SCREEN_UNAVAILABLE);
+ }
+
+ int attempts = connectionAttempts;
+ clearActiveState();
+ PortInvocation invocation = invoke(screenPort::showTitleScreen);
+ return new ReconnectDecision(
+ ReconnectOutcome.CANCELLED,
+ ReconnectReason.MANUAL_CANCEL,
+ ReconnectState.STOPPED,
+ ReconnectAction.SHOW_TITLE_SCREEN,
+ invocation.status(),
+ OptionalLong.empty(),
+ OptionalLong.empty(),
+ attempts);
+ }
+
+ private ReconnectDecision tickConnecting(ReconnectSnapshot snapshot) {
+ Observation connection = snapshot.connection();
+ if (connection.isUnknown()) {
+ return running(ReconnectReason.CONNECTION_UNKNOWN);
+ }
+ if (isSingleplayer(connection)) {
+ return finish(ReconnectOutcome.FAILED,
+ ReconnectReason.SINGLEPLAYER_UNSUPPORTED,
+ ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ }
+ if (connection.isPresent()) {
+ if (awaitingInitialDisconnect) {
+ return running(ReconnectReason.INITIAL_DISCONNECT_PENDING);
+ }
+ state = ReconnectState.LOBBY;
+ dueAtMillis = saturatedAdd(
+ snapshot.nowMillis(), policy.connectionSettleDelayMillis());
+ gameStateGenerationFloor = snapshot.gameState().generation();
+ return running(ReconnectReason.CONNECTION_ESTABLISHED);
+ }
+
+ awaitingInitialDisconnect = false;
+ if (snapshot.nowMillis() < dueAtMillis) {
+ return running(ReconnectReason.DELAY_PENDING);
+ }
+
+ connectionAttempts = increment(connectionAttempts);
+ PortInvocation invocation = invoke(connectionPort::connect);
+ if (invocation.failed()) {
+ return finish(ReconnectOutcome.FAILED, ReconnectReason.PORT_FAILURE,
+ ReconnectAction.CONNECT, ReconnectActionStatus.FAILED);
+ }
+ if (invocation.accepted()) {
+ state = ReconnectState.LOBBY;
+ dueAtMillis = saturatedAdd(
+ snapshot.nowMillis(), policy.connectionSettleDelayMillis());
+ gameStateGenerationFloor = snapshot.gameState().generation();
+ } else {
+ dueAtMillis = saturatedAdd(snapshot.nowMillis(), policy.retryDelayMillis());
+ }
+ return running(ReconnectReason.CONNECT_ATTEMPTED,
+ ReconnectAction.CONNECT, invocation.status());
+ }
+
+ private ReconnectDecision tickTravel(ReconnectSnapshot snapshot) {
+ if (snapshot.nowMillis() < dueAtMillis) {
+ return running(ReconnectReason.DELAY_PENDING);
+ }
+
+ Observation connection = snapshot.connection();
+ if (connection.isUnknown()) {
+ return running(ReconnectReason.CONNECTION_UNKNOWN);
+ }
+ if (isSingleplayer(connection)) {
+ return finish(ReconnectOutcome.FAILED,
+ ReconnectReason.SINGLEPLAYER_UNSUPPORTED,
+ ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ }
+ if (connection.isAbsent()) {
+ state = ReconnectState.CONNECTING;
+ awaitingInitialDisconnect = false;
+ dueAtMillis = saturatedAdd(snapshot.nowMillis(), policy.retryDelayMillis());
+ gameStateGenerationFloor = snapshot.gameState().generation();
+ return running(ReconnectReason.CONNECTION_LOST);
+ }
+
+ GameStateSnapshot gameState = snapshot.gameState();
+ if (gameState.generation() <= gameStateGenerationFloor) {
+ return running(ReconnectReason.GAME_STATE_NOT_FRESH);
+ }
+
+ return switch (route(gameState)) {
+ case UNKNOWN -> running(ReconnectReason.GAME_STATE_UNKNOWN);
+ case CONTRADICTORY -> running(ReconnectReason.GAME_STATE_CONTRADICTORY);
+ case TRANSITIONING -> running(ReconnectReason.GAME_STATE_TRANSITIONING);
+ case GARDEN -> finish(
+ ReconnectOutcome.SUCCEEDED,
+ ReconnectReason.GARDEN_REACHED,
+ ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ case LOBBY -> routeFromLobby(snapshot);
+ case LIMBO -> invokeTravel(
+ snapshot,
+ ReconnectReason.LIMBO_OBSERVED,
+ ReconnectAction.RETURN_TO_LOBBY,
+ travelPort::returnToLobby,
+ ReconnectState.LOBBY,
+ policy.travelDelayMillis());
+ case OUTSIDE -> invokeTravel(
+ snapshot,
+ ReconnectReason.OUTSIDE_GARDEN,
+ ReconnectAction.WARP_GARDEN,
+ travelPort::warpToGarden,
+ state,
+ policy.travelDelayMillis());
+ };
+ }
+
+ private ReconnectDecision routeFromLobby(ReconnectSnapshot snapshot) {
+ if (state == ReconnectState.GARDEN) {
+ state = ReconnectState.LOBBY;
+ dueAtMillis = saturatedAdd(
+ snapshot.nowMillis(), policy.lobbyReturnDelayMillis());
+ gameStateGenerationFloor = snapshot.gameState().generation();
+ return running(ReconnectReason.LOBBY_OBSERVED);
+ }
+ return invokeTravel(
+ snapshot,
+ ReconnectReason.LOBBY_OBSERVED,
+ ReconnectAction.ENTER_SKYBLOCK,
+ travelPort::enterSkyBlock,
+ ReconnectState.GARDEN,
+ policy.travelDelayMillis());
+ }
+
+ private ReconnectDecision invokeTravel(
+ ReconnectSnapshot snapshot,
+ ReconnectReason reason,
+ ReconnectAction action,
+ BooleanSupplier request,
+ ReconnectState acceptedState,
+ long acceptedDelayMillis
+ ) {
+ PortInvocation invocation = invoke(request);
+ if (invocation.failed()) {
+ return finish(ReconnectOutcome.FAILED, ReconnectReason.PORT_FAILURE,
+ action, ReconnectActionStatus.FAILED);
+ }
+ if (invocation.accepted()) {
+ state = acceptedState;
+ dueAtMillis = saturatedAdd(snapshot.nowMillis(), acceptedDelayMillis);
+ gameStateGenerationFloor = snapshot.gameState().generation();
+ } else {
+ dueAtMillis = saturatedAdd(snapshot.nowMillis(), policy.retryDelayMillis());
+ }
+ return running(reason, action, invocation.status());
+ }
+
+ private ReconnectDecision running(ReconnectReason reason) {
+ return running(reason, ReconnectAction.NONE, ReconnectActionStatus.NOT_REQUESTED);
+ }
+
+ private ReconnectDecision running(
+ ReconnectReason reason,
+ ReconnectAction action,
+ ReconnectActionStatus actionStatus
+ ) {
+ return new ReconnectDecision(
+ ReconnectOutcome.RUNNING,
+ reason,
+ state,
+ action,
+ actionStatus,
+ OptionalLong.of(dueAtMillis),
+ OptionalLong.of(deadlineAtMillis),
+ connectionAttempts);
+ }
+
+ private ReconnectDecision inactive(
+ ReconnectOutcome outcome,
+ ReconnectReason reason
+ ) {
+ return new ReconnectDecision(
+ outcome,
+ reason,
+ ReconnectState.STOPPED,
+ ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED,
+ OptionalLong.empty(),
+ OptionalLong.empty(),
+ 0);
+ }
+
+ private ReconnectDecision finish(
+ ReconnectOutcome outcome,
+ ReconnectReason reason,
+ ReconnectAction action,
+ ReconnectActionStatus actionStatus
+ ) {
+ int attempts = connectionAttempts;
+ clearActiveState();
+ return new ReconnectDecision(
+ outcome,
+ reason,
+ ReconnectState.STOPPED,
+ action,
+ actionStatus,
+ OptionalLong.empty(),
+ OptionalLong.empty(),
+ attempts);
+ }
+
+ private void clearActiveState() {
+ state = ReconnectState.STOPPED;
+ dueAtMillis = -1L;
+ deadlineAtMillis = -1L;
+ gameStateGenerationFloor = -1L;
+ connectionAttempts = 0;
+ awaitingInitialDisconnect = false;
+ }
+
+ private PortInvocation invoke(BooleanSupplier request) {
+ try {
+ return request.getAsBoolean()
+ ? PortInvocation.acceptedInvocation()
+ : PortInvocation.rejectedInvocation();
+ } catch (RuntimeException | Error failure) {
+ lastPortFailure = failure;
+ return PortInvocation.failedInvocation();
+ }
+ }
+
+ private void observeNow(long nowMillis) {
+ if (lastNowMillis >= 0L && nowMillis < lastNowMillis) {
+ throw new IllegalArgumentException("nowMillis must not move backwards");
+ }
+ lastNowMillis = nowMillis;
+ }
+
+ private static boolean isSingleplayer(Observation connection) {
+ return connection.isPresent()
+ && connection.get().mode() == ConnectionSnapshot.Mode.SINGLEPLAYER;
+ }
+
+ private static boolean isDisconnectedScreen(Observation screen) {
+ return screen.isPresent()
+ && screen.get().type().isPresent()
+ && DISCONNECTED_SCREEN_TYPE.equals(screen.get().type().get());
+ }
+
+ private static Route route(GameStateSnapshot gameState) {
+ Observation inGarden = gameState.inGarden();
+ if (!inGarden.isPresent()) {
+ return Route.UNKNOWN;
+ }
+ if (inGarden.get()) {
+ return Route.GARDEN;
+ }
+
+ Observation location = gameState.location();
+ if (!location.isPresent()) {
+ return Route.UNKNOWN;
+ }
+ return switch (location.get()) {
+ case GARDEN -> Route.CONTRADICTORY;
+ case TELEPORTING -> Route.TRANSITIONING;
+ case LOBBY -> Route.LOBBY;
+ case LIMBO -> Route.LIMBO;
+ default -> Route.OUTSIDE;
+ };
+ }
+
+ private static int increment(int value) {
+ return value == Integer.MAX_VALUE ? value : value + 1;
+ }
+
+ private static long saturatedAdd(long left, long right) {
+ return right > Long.MAX_VALUE - left ? Long.MAX_VALUE : left + right;
+ }
+
+ private enum Route {
+ UNKNOWN,
+ CONTRADICTORY,
+ TRANSITIONING,
+ GARDEN,
+ LOBBY,
+ LIMBO,
+ OUTSIDE
+ }
+
+ private record PortInvocation(ReconnectActionStatus status) {
+ private boolean accepted() {
+ return status == ReconnectActionStatus.ACCEPTED;
+ }
+
+ private boolean failed() {
+ return status == ReconnectActionStatus.FAILED;
+ }
+
+ private static PortInvocation acceptedInvocation() {
+ return new PortInvocation(ReconnectActionStatus.ACCEPTED);
+ }
+
+ private static PortInvocation rejectedInvocation() {
+ return new PortInvocation(ReconnectActionStatus.REJECTED);
+ }
+
+ private static PortInvocation failedInvocation() {
+ return new PortInvocation(ReconnectActionStatus.FAILED);
+ }
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectAction.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectAction.java
new file mode 100644
index 00000000..cc465f61
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectAction.java
@@ -0,0 +1,12 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Semantic client action requested through a reconnect port. */
+public enum ReconnectAction {
+ NONE,
+ DISCONNECT,
+ CONNECT,
+ ENTER_SKYBLOCK,
+ RETURN_TO_LOBBY,
+ WARP_GARDEN,
+ SHOW_TITLE_SCREEN
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectActionStatus.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectActionStatus.java
new file mode 100644
index 00000000..b5780f3f
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectActionStatus.java
@@ -0,0 +1,9 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Result of requesting the action carried by a reconnect decision. */
+public enum ReconnectActionStatus {
+ NOT_REQUESTED,
+ ACCEPTED,
+ REJECTED,
+ FAILED
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectConnectionPort.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectConnectionPort.java
new file mode 100644
index 00000000..099bb424
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectConnectionPort.java
@@ -0,0 +1,8 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Platform connection actions; the adapter owns the configured server target and disconnect text. */
+public interface ReconnectConnectionPort {
+ boolean disconnect();
+
+ boolean connect();
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectDecision.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectDecision.java
new file mode 100644
index 00000000..34eef207
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectDecision.java
@@ -0,0 +1,46 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+import java.util.Objects;
+import java.util.OptionalLong;
+
+/** Immutable result of a start, tick, or manual-cancel request. */
+public record ReconnectDecision(
+ ReconnectOutcome outcome,
+ ReconnectReason reason,
+ ReconnectState state,
+ ReconnectAction action,
+ ReconnectActionStatus actionStatus,
+ OptionalLong dueAtMillis,
+ OptionalLong deadlineAtMillis,
+ int connectionAttempts
+) {
+ public ReconnectDecision {
+ Objects.requireNonNull(outcome, "outcome");
+ Objects.requireNonNull(reason, "reason");
+ Objects.requireNonNull(state, "state");
+ Objects.requireNonNull(action, "action");
+ Objects.requireNonNull(actionStatus, "actionStatus");
+ Objects.requireNonNull(dueAtMillis, "dueAtMillis");
+ Objects.requireNonNull(deadlineAtMillis, "deadlineAtMillis");
+ if (connectionAttempts < 0) {
+ throw new IllegalArgumentException("connectionAttempts must be non-negative");
+ }
+ if (dueAtMillis.isPresent() && dueAtMillis.getAsLong() < 0L) {
+ throw new IllegalArgumentException("dueAtMillis must be non-negative");
+ }
+ if (deadlineAtMillis.isPresent() && deadlineAtMillis.getAsLong() < 0L) {
+ throw new IllegalArgumentException("deadlineAtMillis must be non-negative");
+ }
+ boolean running = outcome == ReconnectOutcome.RUNNING;
+ if (running != (state != ReconnectState.STOPPED)) {
+ throw new IllegalArgumentException("only a running decision may have an active state");
+ }
+ if (running != (dueAtMillis.isPresent() && deadlineAtMillis.isPresent())) {
+ throw new IllegalArgumentException("active timing must exist exactly while running");
+ }
+ if ((action == ReconnectAction.NONE)
+ != (actionStatus == ReconnectActionStatus.NOT_REQUESTED)) {
+ throw new IllegalArgumentException("action and actionStatus do not agree");
+ }
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectOutcome.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectOutcome.java
new file mode 100644
index 00000000..1b25b030
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectOutcome.java
@@ -0,0 +1,12 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Whether one controller decision is active, terminal, or a rejected request. */
+public enum ReconnectOutcome {
+ IDLE,
+ RUNNING,
+ SUCCEEDED,
+ CANCELLED,
+ TIMED_OUT,
+ FAILED,
+ REJECTED
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectPolicy.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectPolicy.java
new file mode 100644
index 00000000..d29aff03
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectPolicy.java
@@ -0,0 +1,41 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Delay policy for a bounded reconnect run. */
+public record ReconnectPolicy(
+ long connectionSettleDelayMillis,
+ long retryDelayMillis,
+ long travelDelayMillis,
+ long lobbyReturnDelayMillis,
+ long timeoutMillis
+) {
+ public static final long UPSTREAM_CONNECTION_SETTLE_DELAY_MILLIS = 7_500L;
+ public static final long UPSTREAM_RETRY_DELAY_MILLIS = 5_000L;
+ public static final long UPSTREAM_TRAVEL_DELAY_MILLIS = 5_000L;
+ public static final long UPSTREAM_LOBBY_RETURN_DELAY_MILLIS = 60_000L;
+
+ public ReconnectPolicy {
+ requireNonNegative(connectionSettleDelayMillis, "connectionSettleDelayMillis");
+ requireNonNegative(retryDelayMillis, "retryDelayMillis");
+ requireNonNegative(travelDelayMillis, "travelDelayMillis");
+ requireNonNegative(lobbyReturnDelayMillis, "lobbyReturnDelayMillis");
+ if (timeoutMillis <= 0L) {
+ throw new IllegalArgumentException("timeoutMillis must be positive");
+ }
+ }
+
+ /** Preserves the pinned upstream delays while requiring an explicit local timeout. */
+ public static ReconnectPolicy upstreamDurations(long timeoutMillis) {
+ return new ReconnectPolicy(
+ UPSTREAM_CONNECTION_SETTLE_DELAY_MILLIS,
+ UPSTREAM_RETRY_DELAY_MILLIS,
+ UPSTREAM_TRAVEL_DELAY_MILLIS,
+ UPSTREAM_LOBBY_RETURN_DELAY_MILLIS,
+ timeoutMillis);
+ }
+
+ private static void requireNonNegative(long value, String name) {
+ if (value < 0L) {
+ throw new IllegalArgumentException(name + " must be non-negative");
+ }
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectReason.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectReason.java
new file mode 100644
index 00000000..c25eb692
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectReason.java
@@ -0,0 +1,28 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Typed explanation for a reconnect decision. */
+public enum ReconnectReason {
+ IDLE,
+ STARTED,
+ ALREADY_RUNNING,
+ DELAY_PENDING,
+ INITIAL_DISCONNECT_PENDING,
+ CONNECTION_UNKNOWN,
+ CONNECT_ATTEMPTED,
+ CONNECTION_ESTABLISHED,
+ CONNECTION_LOST,
+ GAME_STATE_NOT_FRESH,
+ GAME_STATE_UNKNOWN,
+ GAME_STATE_CONTRADICTORY,
+ GAME_STATE_TRANSITIONING,
+ LOBBY_OBSERVED,
+ LIMBO_OBSERVED,
+ OUTSIDE_GARDEN,
+ GARDEN_REACHED,
+ MANUAL_CANCEL,
+ STOP_REQUESTED,
+ CANCEL_SCREEN_UNAVAILABLE,
+ SINGLEPLAYER_UNSUPPORTED,
+ PORT_FAILURE,
+ DEADLINE_REACHED
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectScreenPort.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectScreenPort.java
new file mode 100644
index 00000000..826f158c
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectScreenPort.java
@@ -0,0 +1,7 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Screen action used only after a valid manual cancellation. */
+@FunctionalInterface
+public interface ReconnectScreenPort {
+ boolean showTitleScreen();
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectSnapshot.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectSnapshot.java
new file mode 100644
index 00000000..1618efd2
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectSnapshot.java
@@ -0,0 +1,25 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+import dev.hylfrd.farmhelper.runtime.gamestate.GameStateSnapshot;
+import dev.hylfrd.farmhelper.runtime.snapshot.ConnectionSnapshot;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.ScreenSnapshot;
+
+import java.util.Objects;
+
+/** One immutable reconnect observation with an explicit monotonic millisecond reading. */
+public record ReconnectSnapshot(
+ long nowMillis,
+ Observation connection,
+ Observation screen,
+ GameStateSnapshot gameState
+) {
+ public ReconnectSnapshot {
+ if (nowMillis < 0L) {
+ throw new IllegalArgumentException("nowMillis must be non-negative");
+ }
+ Objects.requireNonNull(connection, "connection");
+ Objects.requireNonNull(screen, "screen");
+ Objects.requireNonNull(gameState, "gameState");
+ }
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectState.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectState.java
new file mode 100644
index 00000000..f6cad27f
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectState.java
@@ -0,0 +1,9 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Upstream AutoReconnect phases, with NONE renamed to an explicit inactive state. */
+public enum ReconnectState {
+ STOPPED,
+ CONNECTING,
+ LOBBY,
+ GARDEN
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectTravelPort.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectTravelPort.java
new file mode 100644
index 00000000..60a3ce26
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectTravelPort.java
@@ -0,0 +1,10 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+/** Semantic server-travel actions used after a connection has been established. */
+public interface ReconnectTravelPort {
+ boolean enterSkyBlock();
+
+ boolean returnToLobby();
+
+ boolean warpToGarden();
+}
diff --git a/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/package-info.java b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/package-info.java
new file mode 100644
index 00000000..c75b98de
--- /dev/null
+++ b/src/main/java/dev/hylfrd/farmhelper/feature/reconnect/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * Minecraft-free AutoReconnect state, snapshot inputs, decisions, and semantic action ports.
+ * Lifecycle composition is intentionally deferred.
+ */
+package dev.hylfrd.farmhelper.feature.reconnect;
diff --git a/src/test/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectControllerTest.java b/src/test/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectControllerTest.java
new file mode 100644
index 00000000..bff60664
--- /dev/null
+++ b/src/test/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectControllerTest.java
@@ -0,0 +1,605 @@
+package dev.hylfrd.farmhelper.feature.reconnect;
+
+import dev.hylfrd.farmhelper.runtime.gamestate.BuffSnapshot;
+import dev.hylfrd.farmhelper.runtime.gamestate.EconomySnapshot;
+import dev.hylfrd.farmhelper.runtime.gamestate.GameStateSnapshot;
+import dev.hylfrd.farmhelper.runtime.gamestate.GardenStateSnapshot;
+import dev.hylfrd.farmhelper.runtime.gamestate.JacobStateSnapshot;
+import dev.hylfrd.farmhelper.runtime.gamestate.SemanticLocation;
+import dev.hylfrd.farmhelper.runtime.snapshot.ConnectionSnapshot;
+import dev.hylfrd.farmhelper.runtime.snapshot.Observation;
+import dev.hylfrd.farmhelper.runtime.snapshot.ScreenSnapshot;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+
+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 AutoReconnectControllerTest {
+ private static final long DEFAULT_TIMEOUT_MILLIS = 300_000L;
+
+ @Test
+ void connectedStartWaitsForObservedDisconnectBeforeConnecting() {
+ Harness harness = harness();
+
+ ReconnectDecision started = harness.controller.start(snapshot(
+ 0L, multiplayer(), Observation.absent(), unknownGame(0L)), 5_000L);
+
+ assertDecision(started, ReconnectOutcome.RUNNING, ReconnectReason.STARTED,
+ ReconnectState.CONNECTING, ReconnectAction.DISCONNECT,
+ ReconnectActionStatus.ACCEPTED);
+ assertEquals(5_000L, started.dueAtMillis().orElseThrow());
+ assertEquals(DEFAULT_TIMEOUT_MILLIS, started.deadlineAtMillis().orElseThrow());
+
+ ReconnectDecision stillConnected = harness.controller.tick(snapshot(
+ 5_000L, multiplayer(), Observation.absent(), unknownGame(1L)));
+ assertDecision(stillConnected, ReconnectOutcome.RUNNING,
+ ReconnectReason.INITIAL_DISCONNECT_PENDING,
+ ReconnectState.CONNECTING, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertEquals(List.of(ReconnectAction.DISCONNECT), harness.ports.actions);
+
+ ReconnectDecision connecting = harness.controller.tick(snapshot(
+ 5_000L, Observation.absent(), disconnectedScreen(), unknownGame(2L)));
+ assertDecision(connecting, ReconnectOutcome.RUNNING,
+ ReconnectReason.CONNECT_ATTEMPTED,
+ ReconnectState.LOBBY, ReconnectAction.CONNECT,
+ ReconnectActionStatus.ACCEPTED);
+ assertEquals(12_500L, connecting.dueAtMillis().orElseThrow());
+ assertEquals(1, connecting.connectionAttempts());
+ assertEquals(List.of(ReconnectAction.DISCONNECT, ReconnectAction.CONNECT),
+ harness.ports.actions);
+ }
+
+ @Test
+ void initialDelayAndRejectedConnectionAttemptUseExactUpstreamRetryDelays() {
+ Harness harness = harness();
+ harness.ports.connectResults.add(false);
+ harness.ports.connectResults.add(true);
+
+ ReconnectDecision started = harness.controller.start(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 5_000L);
+ assertDecision(started, ReconnectOutcome.RUNNING, ReconnectReason.STARTED,
+ ReconnectState.CONNECTING, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+
+ ReconnectDecision early = harness.controller.tick(snapshot(
+ 4_999L, Observation.absent(), disconnectedScreen(), unknownGame(1L)));
+ assertEquals(ReconnectReason.DELAY_PENDING, early.reason());
+ assertTrue(harness.ports.actions.isEmpty());
+
+ ReconnectDecision rejected = harness.controller.tick(snapshot(
+ 5_000L, Observation.absent(), disconnectedScreen(), unknownGame(2L)));
+ assertDecision(rejected, ReconnectOutcome.RUNNING,
+ ReconnectReason.CONNECT_ATTEMPTED,
+ ReconnectState.CONNECTING, ReconnectAction.CONNECT,
+ ReconnectActionStatus.REJECTED);
+ assertEquals(10_000L, rejected.dueAtMillis().orElseThrow());
+ assertEquals(1, rejected.connectionAttempts());
+
+ harness.controller.tick(snapshot(
+ 9_999L, Observation.absent(), disconnectedScreen(), unknownGame(3L)));
+ assertEquals(1, harness.ports.actions.size());
+
+ ReconnectDecision accepted = harness.controller.tick(snapshot(
+ 10_000L, Observation.absent(), disconnectedScreen(), unknownGame(4L)));
+ assertDecision(accepted, ReconnectOutcome.RUNNING,
+ ReconnectReason.CONNECT_ATTEMPTED,
+ ReconnectState.LOBBY, ReconnectAction.CONNECT,
+ ReconnectActionStatus.ACCEPTED);
+ assertEquals(17_500L, accepted.dueAtMillis().orElseThrow());
+ assertEquals(2, accepted.connectionAttempts());
+ }
+
+ @Test
+ void repeatedStartIsIdempotentAndAnExternalConnectionIsNotDuplicated() {
+ Harness harness = harness();
+ ReconnectDecision started = harness.controller.start(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 5_000L);
+
+ ReconnectDecision duplicate = harness.controller.start(snapshot(
+ 1L, multiplayer(), Observation.absent(), unknownGame(1L)), 0L);
+ assertDecision(duplicate, ReconnectOutcome.RUNNING,
+ ReconnectReason.ALREADY_RUNNING,
+ ReconnectState.CONNECTING, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertEquals(started.deadlineAtMillis(), duplicate.deadlineAtMillis());
+ assertTrue(harness.ports.actions.isEmpty());
+
+ ReconnectDecision established = harness.controller.tick(snapshot(
+ 2L, multiplayer(), Observation.absent(), unknownGame(2L)));
+ assertDecision(established, ReconnectOutcome.RUNNING,
+ ReconnectReason.CONNECTION_ESTABLISHED,
+ ReconnectState.LOBBY, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertEquals(7_502L, established.dueAtMillis().orElseThrow());
+ assertTrue(harness.ports.actions.isEmpty());
+ }
+
+ @Test
+ void freshLobbyAndOutsideEvidenceRouteToGardenThenComplete() {
+ Harness harness = connectedHarness();
+
+ ReconnectDecision stale = harness.controller.tick(snapshot(
+ 7_500L, multiplayer(), Observation.absent(), game(
+ 0L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ assertEquals(ReconnectReason.GAME_STATE_NOT_FRESH, stale.reason());
+ assertEquals(List.of(ReconnectAction.CONNECT), harness.ports.actions);
+
+ ReconnectDecision skyBlock = harness.controller.tick(snapshot(
+ 7_501L, multiplayer(), Observation.absent(), game(
+ 1L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ assertDecision(skyBlock, ReconnectOutcome.RUNNING,
+ ReconnectReason.LOBBY_OBSERVED,
+ ReconnectState.GARDEN, ReconnectAction.ENTER_SKYBLOCK,
+ ReconnectActionStatus.ACCEPTED);
+ assertEquals(12_501L, skyBlock.dueAtMillis().orElseThrow());
+
+ ReconnectDecision warp = harness.controller.tick(snapshot(
+ 12_501L, multiplayer(), Observation.absent(), game(
+ 2L, Observation.present(SemanticLocation.HUB),
+ Observation.present(false))));
+ assertDecision(warp, ReconnectOutcome.RUNNING,
+ ReconnectReason.OUTSIDE_GARDEN,
+ ReconnectState.GARDEN, ReconnectAction.WARP_GARDEN,
+ ReconnectActionStatus.ACCEPTED);
+ assertEquals(17_501L, warp.dueAtMillis().orElseThrow());
+
+ ReconnectDecision completed = harness.controller.tick(snapshot(
+ 17_501L, multiplayer(), Observation.absent(), game(
+ 3L, Observation.present(SemanticLocation.GARDEN),
+ Observation.present(true))));
+ assertDecision(completed, ReconnectOutcome.SUCCEEDED,
+ ReconnectReason.GARDEN_REACHED,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertFalse(harness.controller.active());
+ assertTrue(harness.controller.dueAtMillis().isEmpty());
+ assertEquals(List.of(
+ ReconnectAction.CONNECT,
+ ReconnectAction.ENTER_SKYBLOCK,
+ ReconnectAction.WARP_GARDEN), harness.ports.actions);
+ }
+
+ @Test
+ void limboReturnsToLobbyAndGardenLobbyBounceKeepsSixtySecondCooldown() {
+ Harness limbo = connectedHarness();
+ ReconnectDecision lobbyReturn = limbo.controller.tick(snapshot(
+ 7_500L, multiplayer(), Observation.absent(), game(
+ 1L, Observation.present(SemanticLocation.LIMBO),
+ Observation.present(false))));
+ assertDecision(lobbyReturn, ReconnectOutcome.RUNNING,
+ ReconnectReason.LIMBO_OBSERVED,
+ ReconnectState.LOBBY, ReconnectAction.RETURN_TO_LOBBY,
+ ReconnectActionStatus.ACCEPTED);
+ assertEquals(12_500L, lobbyReturn.dueAtMillis().orElseThrow());
+
+ Harness bounce = connectedHarness();
+ bounce.controller.tick(snapshot(
+ 7_500L, multiplayer(), Observation.absent(), game(
+ 1L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ ReconnectDecision bounced = bounce.controller.tick(snapshot(
+ 12_500L, multiplayer(), Observation.absent(), game(
+ 2L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+
+ assertDecision(bounced, ReconnectOutcome.RUNNING,
+ ReconnectReason.LOBBY_OBSERVED,
+ ReconnectState.LOBBY, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertEquals(72_500L, bounced.dueAtMillis().orElseThrow());
+ assertEquals(List.of(ReconnectAction.CONNECT, ReconnectAction.ENTER_SKYBLOCK),
+ bounce.ports.actions);
+
+ bounce.controller.tick(snapshot(
+ 72_499L, multiplayer(), Observation.absent(), game(
+ 3L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ assertEquals(2, bounce.ports.actions.size());
+
+ ReconnectDecision reentered = bounce.controller.tick(snapshot(
+ 72_500L, multiplayer(), Observation.absent(), game(
+ 4L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ assertEquals(ReconnectAction.ENTER_SKYBLOCK, reentered.action());
+ assertEquals(ReconnectState.GARDEN, reentered.state());
+ }
+
+ @Test
+ void connectionLossFromTravelStateSchedulesANewAttempt() {
+ Harness harness = connectedHarness();
+
+ ReconnectDecision lost = harness.controller.tick(snapshot(
+ 7_500L, Observation.absent(), disconnectedScreen(), unknownGame(1L)));
+ assertDecision(lost, ReconnectOutcome.RUNNING,
+ ReconnectReason.CONNECTION_LOST,
+ ReconnectState.CONNECTING, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertEquals(12_500L, lost.dueAtMillis().orElseThrow());
+
+ harness.controller.tick(snapshot(
+ 12_499L, Observation.absent(), disconnectedScreen(), unknownGame(2L)));
+ assertEquals(List.of(ReconnectAction.CONNECT), harness.ports.actions);
+
+ ReconnectDecision retry = harness.controller.tick(snapshot(
+ 12_500L, Observation.absent(), disconnectedScreen(), unknownGame(3L)));
+ assertEquals(ReconnectAction.CONNECT, retry.action());
+ assertEquals(2, retry.connectionAttempts());
+ }
+
+ @Test
+ void unknownStaleTransitioningAndContradictoryEvidenceNeverTravels() {
+ Harness rejected = harness();
+ ReconnectDecision unknownStart = rejected.controller.start(snapshot(
+ 0L, Observation.unknown(), Observation.unknown(), unknownGame(0L)), 0L);
+ assertDecision(unknownStart, ReconnectOutcome.REJECTED,
+ ReconnectReason.CONNECTION_UNKNOWN,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+
+ Harness harness = connectedHarness();
+ assertEquals(ReconnectReason.CONNECTION_UNKNOWN, harness.controller.tick(snapshot(
+ 7_500L, Observation.unknown(), Observation.unknown(), unknownGame(1L))).reason());
+ assertEquals(ReconnectReason.GAME_STATE_UNKNOWN, harness.controller.tick(snapshot(
+ 7_501L, multiplayer(), Observation.absent(), unknownGame(1L))).reason());
+ assertEquals(ReconnectReason.GAME_STATE_CONTRADICTORY,
+ harness.controller.tick(snapshot(
+ 7_502L, multiplayer(), Observation.absent(), game(
+ 2L, Observation.present(SemanticLocation.GARDEN),
+ Observation.present(false)))).reason());
+ assertEquals(ReconnectReason.GAME_STATE_TRANSITIONING,
+ harness.controller.tick(snapshot(
+ 7_503L, multiplayer(), Observation.absent(), game(
+ 3L, Observation.present(SemanticLocation.TELEPORTING),
+ Observation.present(false)))).reason());
+ assertEquals(List.of(ReconnectAction.CONNECT), harness.ports.actions);
+ }
+
+ @Test
+ void rejectedTravelActionRetriesWithoutAdvancingItsState() {
+ Harness harness = connectedHarness();
+ harness.ports.enterSkyBlockResults.add(false);
+ harness.ports.enterSkyBlockResults.add(true);
+
+ ReconnectDecision rejected = harness.controller.tick(snapshot(
+ 7_500L, multiplayer(), Observation.absent(), game(
+ 1L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ assertDecision(rejected, ReconnectOutcome.RUNNING,
+ ReconnectReason.LOBBY_OBSERVED,
+ ReconnectState.LOBBY, ReconnectAction.ENTER_SKYBLOCK,
+ ReconnectActionStatus.REJECTED);
+ assertEquals(12_500L, rejected.dueAtMillis().orElseThrow());
+
+ ReconnectDecision accepted = harness.controller.tick(snapshot(
+ 12_500L, multiplayer(), Observation.absent(), game(
+ 2L, Observation.present(SemanticLocation.LOBBY),
+ Observation.present(false))));
+ assertDecision(accepted, ReconnectOutcome.RUNNING,
+ ReconnectReason.LOBBY_OBSERVED,
+ ReconnectState.GARDEN, ReconnectAction.ENTER_SKYBLOCK,
+ ReconnectActionStatus.ACCEPTED);
+ }
+
+ @Test
+ void singleplayerIsRejectedBeforeStartAndFailsAnActiveRun() {
+ Harness rejected = harness();
+ ReconnectDecision start = rejected.controller.start(snapshot(
+ 0L, Observation.present(ConnectionSnapshot.singleplayer()),
+ Observation.absent(), unknownGame(0L)), 0L);
+ assertDecision(start, ReconnectOutcome.REJECTED,
+ ReconnectReason.SINGLEPLAYER_UNSUPPORTED,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertTrue(rejected.ports.actions.isEmpty());
+
+ Harness active = connectedHarness();
+ ReconnectDecision failed = active.controller.tick(snapshot(
+ 7_500L, Observation.present(ConnectionSnapshot.singleplayer()),
+ Observation.absent(), unknownGame(1L)));
+ assertDecision(failed, ReconnectOutcome.FAILED,
+ ReconnectReason.SINGLEPLAYER_UNSUPPORTED,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ }
+
+ @Test
+ void manualCancelRequiresDisconnectedScreenAndStopsDespiteUiFailure() {
+ Harness harness = harness();
+ harness.controller.start(snapshot(
+ 0L, Observation.absent(), Observation.unknown(), unknownGame(0L)), 20_000L);
+
+ ReconnectDecision unknown = harness.controller.cancelFromDisconnectedScreen(snapshot(
+ 1L, Observation.absent(), Observation.unknown(), unknownGame(1L)));
+ assertEquals(ReconnectReason.CANCEL_SCREEN_UNAVAILABLE, unknown.reason());
+ assertTrue(harness.controller.active());
+
+ ReconnectDecision wrong = harness.controller.cancelFromDisconnectedScreen(snapshot(
+ 2L, Observation.absent(), screen("net.minecraft.client.gui.screens.TitleScreen"),
+ unknownGame(2L)));
+ assertEquals(ReconnectReason.CANCEL_SCREEN_UNAVAILABLE, wrong.reason());
+
+ harness.ports.showTitleScreenResult = false;
+ ReconnectDecision cancelled = harness.controller.cancelFromDisconnectedScreen(snapshot(
+ 3L, Observation.absent(), disconnectedScreen(), unknownGame(3L)));
+ assertDecision(cancelled, ReconnectOutcome.CANCELLED,
+ ReconnectReason.MANUAL_CANCEL,
+ ReconnectState.STOPPED, ReconnectAction.SHOW_TITLE_SCREEN,
+ ReconnectActionStatus.REJECTED);
+ assertFalse(harness.controller.active());
+
+ Harness throwing = harness();
+ throwing.controller.start(snapshot(
+ 3L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 1L);
+ throwing.ports.throwingAction = ReconnectAction.SHOW_TITLE_SCREEN;
+ ReconnectDecision failedUi = throwing.controller.cancelFromDisconnectedScreen(snapshot(
+ 4L, Observation.absent(), disconnectedScreen(), unknownGame(1L)));
+ assertEquals(ReconnectOutcome.CANCELLED, failedUi.outcome());
+ assertEquals(ReconnectActionStatus.FAILED, failedUi.actionStatus());
+ assertTrue(throwing.controller.lastPortFailure().isPresent());
+ assertFalse(throwing.controller.active());
+ }
+
+ @Test
+ void explicitStopIsIdempotentAndNeverTouchesAPlatformPort() {
+ Harness harness = harness();
+ harness.controller.start(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 20_000L);
+
+ ReconnectDecision stopped = harness.controller.stop();
+ assertDecision(stopped, ReconnectOutcome.CANCELLED,
+ ReconnectReason.STOP_REQUESTED,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertTrue(harness.ports.actions.isEmpty());
+
+ ReconnectDecision duplicate = harness.controller.stop();
+ assertDecision(duplicate, ReconnectOutcome.IDLE,
+ ReconnectReason.IDLE,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertTrue(harness.ports.actions.isEmpty());
+ }
+
+ @Test
+ void exactDeadlinePreventsAnotherAttemptAndDeadlineAdditionSaturates() {
+ Harness deadline = harness(ReconnectPolicy.upstreamDurations(10_000L));
+ deadline.controller.start(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 10_000L);
+ assertEquals(ReconnectReason.DELAY_PENDING, deadline.controller.tick(snapshot(
+ 9_999L, Observation.absent(), disconnectedScreen(), unknownGame(1L))).reason());
+
+ ReconnectDecision timedOut = deadline.controller.tick(snapshot(
+ 10_000L, Observation.absent(), disconnectedScreen(), unknownGame(2L)));
+ assertDecision(timedOut, ReconnectOutcome.TIMED_OUT,
+ ReconnectReason.DEADLINE_REACHED,
+ ReconnectState.STOPPED, ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED);
+ assertTrue(deadline.ports.actions.isEmpty());
+
+ Harness saturated = harness(ReconnectPolicy.upstreamDurations(10L));
+ long start = Long.MAX_VALUE - 5L;
+ ReconnectDecision started = saturated.controller.start(snapshot(
+ start, Observation.absent(), disconnectedScreen(), unknownGame(0L)),
+ Long.MAX_VALUE);
+ assertEquals(Long.MAX_VALUE, started.dueAtMillis().orElseThrow());
+ assertEquals(Long.MAX_VALUE, started.deadlineAtMillis().orElseThrow());
+ assertEquals(ReconnectReason.DELAY_PENDING, saturated.controller.tick(snapshot(
+ Long.MAX_VALUE - 1L, Observation.absent(), disconnectedScreen(),
+ unknownGame(1L))).reason());
+ assertEquals(ReconnectOutcome.TIMED_OUT, saturated.controller.tick(snapshot(
+ Long.MAX_VALUE, Observation.absent(), disconnectedScreen(),
+ unknownGame(2L))).outcome());
+ }
+
+ @Test
+ void adapterExceptionTerminatesBecauseItsSideEffectIsUncertain() {
+ Harness harness = harness();
+ harness.controller.start(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 0L);
+ harness.ports.throwingAction = ReconnectAction.CONNECT;
+
+ ReconnectDecision failed = harness.controller.tick(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(1L)));
+
+ assertDecision(failed, ReconnectOutcome.FAILED,
+ ReconnectReason.PORT_FAILURE,
+ ReconnectState.STOPPED, ReconnectAction.CONNECT,
+ ReconnectActionStatus.FAILED);
+ assertTrue(harness.controller.lastPortFailure().isPresent());
+ assertFalse(harness.controller.active());
+ }
+
+ @Test
+ void validatesPolicyInputDecisionAndMonotonicTimeContracts() {
+ assertThrows(IllegalArgumentException.class,
+ () -> ReconnectPolicy.upstreamDurations(0L));
+ assertThrows(IllegalArgumentException.class,
+ () -> new ReconnectPolicy(-1L, 0L, 0L, 0L, 1L));
+ assertThrows(IllegalArgumentException.class,
+ () -> new ReconnectSnapshot(
+ -1L, Observation.absent(), Observation.absent(), unknownGame(0L)));
+ assertThrows(IllegalArgumentException.class,
+ () -> new ReconnectDecision(
+ ReconnectOutcome.RUNNING,
+ ReconnectReason.STARTED,
+ ReconnectState.STOPPED,
+ ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED,
+ java.util.OptionalLong.of(1L),
+ java.util.OptionalLong.of(2L),
+ 0));
+ assertThrows(IllegalArgumentException.class,
+ () -> new ReconnectDecision(
+ ReconnectOutcome.RUNNING,
+ ReconnectReason.STARTED,
+ ReconnectState.CONNECTING,
+ ReconnectAction.NONE,
+ ReconnectActionStatus.NOT_REQUESTED,
+ java.util.OptionalLong.of(-1L),
+ java.util.OptionalLong.of(2L),
+ 0));
+
+ Harness harness = harness();
+ harness.controller.start(snapshot(
+ 10L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 1L);
+ assertThrows(IllegalArgumentException.class, () -> harness.controller.tick(snapshot(
+ 9L, Observation.absent(), disconnectedScreen(), unknownGame(1L))));
+ assertThrows(IllegalArgumentException.class, () -> harness.controller.start(snapshot(
+ 10L, Observation.absent(), disconnectedScreen(), unknownGame(2L)), -1L));
+ }
+
+ private static Harness connectedHarness() {
+ Harness harness = harness();
+ harness.controller.start(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)), 0L);
+ ReconnectDecision connected = harness.controller.tick(snapshot(
+ 0L, Observation.absent(), disconnectedScreen(), unknownGame(0L)));
+ assertEquals(ReconnectState.LOBBY, connected.state());
+ return harness;
+ }
+
+ private static Harness harness() {
+ return harness(ReconnectPolicy.upstreamDurations(DEFAULT_TIMEOUT_MILLIS));
+ }
+
+ private static Harness harness(ReconnectPolicy policy) {
+ FakePorts ports = new FakePorts();
+ return new Harness(
+ new AutoReconnectController(policy, ports, ports, ports),
+ ports);
+ }
+
+ private static void assertDecision(
+ ReconnectDecision decision,
+ ReconnectOutcome outcome,
+ ReconnectReason reason,
+ ReconnectState state,
+ ReconnectAction action,
+ ReconnectActionStatus actionStatus
+ ) {
+ assertEquals(outcome, decision.outcome());
+ assertEquals(reason, decision.reason());
+ assertEquals(state, decision.state());
+ assertEquals(action, decision.action());
+ assertEquals(actionStatus, decision.actionStatus());
+ }
+
+ private static Observation multiplayer() {
+ return Observation.present(ConnectionSnapshot.multiplayer());
+ }
+
+ private static Observation disconnectedScreen() {
+ return screen(AutoReconnectController.DISCONNECTED_SCREEN_TYPE);
+ }
+
+ private static Observation screen(String type) {
+ return Observation.present(new ScreenSnapshot(
+ 1L, Observation.present(type), Observation.present("screen")));
+ }
+
+ private static ReconnectSnapshot snapshot(
+ long nowMillis,
+ Observation connection,
+ Observation screen,
+ GameStateSnapshot gameState
+ ) {
+ return new ReconnectSnapshot(nowMillis, connection, screen, gameState);
+ }
+
+ private static GameStateSnapshot unknownGame(long generation) {
+ return game(generation, Observation.unknown(), Observation.unknown());
+ }
+
+ private static GameStateSnapshot game(
+ long generation,
+ Observation location,
+ Observation inGarden
+ ) {
+ return new GameStateSnapshot(
+ generation,
+ location,
+ Observation.unknown(),
+ inGarden,
+ Observation.unknown(),
+ new EconomySnapshot(
+ Observation.unknown(), Observation.unknown(),
+ Observation.unknown(), Observation.unknown()),
+ new JacobStateSnapshot(Observation.unknown(), Observation.unknown()),
+ new BuffSnapshot(
+ Observation.unknown(), Observation.unknown(), Observation.unknown(),
+ Observation.unknown(), Observation.unknown(), Observation.unknown()),
+ new GardenStateSnapshot(
+ Observation.unknown(), Observation.unknown(), Observation.unknown(),
+ Observation.unknown(), Observation.unknown(), Observation.unknown(),
+ Observation.unknown()));
+ }
+
+ private record Harness(AutoReconnectController controller, FakePorts ports) {
+ }
+
+ private static final class FakePorts implements
+ ReconnectConnectionPort, ReconnectTravelPort, ReconnectScreenPort {
+ private final List actions = new ArrayList<>();
+ private final Deque disconnectResults = new ArrayDeque<>();
+ private final Deque connectResults = new ArrayDeque<>();
+ private final Deque enterSkyBlockResults = new ArrayDeque<>();
+ private final Deque returnToLobbyResults = new ArrayDeque<>();
+ private final Deque warpToGardenResults = new ArrayDeque<>();
+ private boolean showTitleScreenResult = true;
+ private ReconnectAction throwingAction;
+
+ @Override
+ public boolean disconnect() {
+ return invoke(ReconnectAction.DISCONNECT, disconnectResults);
+ }
+
+ @Override
+ public boolean connect() {
+ return invoke(ReconnectAction.CONNECT, connectResults);
+ }
+
+ @Override
+ public boolean enterSkyBlock() {
+ return invoke(ReconnectAction.ENTER_SKYBLOCK, enterSkyBlockResults);
+ }
+
+ @Override
+ public boolean returnToLobby() {
+ return invoke(ReconnectAction.RETURN_TO_LOBBY, returnToLobbyResults);
+ }
+
+ @Override
+ public boolean warpToGarden() {
+ return invoke(ReconnectAction.WARP_GARDEN, warpToGardenResults);
+ }
+
+ @Override
+ public boolean showTitleScreen() {
+ actions.add(ReconnectAction.SHOW_TITLE_SCREEN);
+ throwIfRequested(ReconnectAction.SHOW_TITLE_SCREEN);
+ return showTitleScreenResult;
+ }
+
+ private boolean invoke(ReconnectAction action, Deque results) {
+ actions.add(action);
+ throwIfRequested(action);
+ return results.isEmpty() || results.remove();
+ }
+
+ private void throwIfRequested(ReconnectAction action) {
+ if (throwingAction == action) {
+ throw new IllegalStateException("scripted " + action + " failure");
+ }
+ }
+ }
+}