From f93e210cb407bbea97c63288205065c2e22059c1 Mon Sep 17 00:00:00 2001 From: Hylfrd Date: Mon, 31 Aug 2026 10:31:16 +0800 Subject: [PATCH] Add pure auto-reconnect state machine --- .../reconnect/AutoReconnectController.java | 476 ++++++++++++++ .../feature/reconnect/ReconnectAction.java | 12 + .../reconnect/ReconnectActionStatus.java | 9 + .../reconnect/ReconnectConnectionPort.java | 8 + .../feature/reconnect/ReconnectDecision.java | 46 ++ .../feature/reconnect/ReconnectOutcome.java | 12 + .../feature/reconnect/ReconnectPolicy.java | 41 ++ .../feature/reconnect/ReconnectReason.java | 28 + .../reconnect/ReconnectScreenPort.java | 7 + .../feature/reconnect/ReconnectSnapshot.java | 25 + .../feature/reconnect/ReconnectState.java | 9 + .../reconnect/ReconnectTravelPort.java | 10 + .../feature/reconnect/package-info.java | 5 + .../AutoReconnectControllerTest.java | 605 ++++++++++++++++++ 14 files changed, 1293 insertions(+) create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectController.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectAction.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectActionStatus.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectConnectionPort.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectDecision.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectOutcome.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectPolicy.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectReason.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectScreenPort.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectSnapshot.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectState.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/ReconnectTravelPort.java create mode 100644 src/main/java/dev/hylfrd/farmhelper/feature/reconnect/package-info.java create mode 100644 src/test/java/dev/hylfrd/farmhelper/feature/reconnect/AutoReconnectControllerTest.java 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"); + } + } + } +}