diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java index 830b0c83d73..682913e5f84 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPlugin.java @@ -51,7 +51,11 @@ protected void shutDown() { @Subscribe public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { - accountSelectorScript.suppressAutoLogin(); + if (event.isRenewal()) { + accountSelectorScript.renewAutoLoginSuppression(); + } else { + accountSelectorScript.suppressAutoLogin(); + } } @Subscribe diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java index cb4ef480efc..d7033af1d04 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginScript.java @@ -92,6 +92,10 @@ void suppressAutoLogin() { suppression.request(); } + void renewAutoLoginSuppression() { + suppression.renew(); + } + void resumeAutoLogin() { suppression.clear(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java index 255776e2797..3854b97d27c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java @@ -8,7 +8,7 @@ final class AutoLoginSuppression { private enum State { ALLOWED, AWAITING_LOGOUT, LOGGED_OUT } - static final long AWAIT_LOGOUT_MILLIS = 15_000L; + static final long AWAIT_LOGOUT_MILLIS = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS; private final LongSupplier clock; private State state = State.ALLOWED; private long requestedAt; @@ -29,6 +29,13 @@ synchronized void request() { } } + synchronized void renew() { + expirePendingRequest(); + if (state == State.AWAITING_LOGOUT) { + requestedAt = clock.getAsLong(); + } + } + synchronized void observe(GameState gameState) { expirePendingRequest(); if (state == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java index d157c74db32..b122abedf0b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java @@ -3,7 +3,29 @@ /** * Scripts post this event before a terminal logout to prevent automatic re-login. * AutoLogin resumes when another script starts, after a manual login, or on AutoLogin restart. + * A request that does not reach the login screen within {@link #PENDING_LOGOUT_MILLIS} expires. + * A renewal restarts that deadline only while the earlier request is still pending, so it never + * re-blocks AutoLogin after a restart or a manual login cleared it. * Ordinary logouts must not post this event. */ public final class AutoLoginSuppressionRequest { + public static final long PENDING_LOGOUT_MILLIS = 15_000L; + + private final boolean renewal; + + public AutoLoginSuppressionRequest() { + this(false); + } + + private AutoLoginSuppressionRequest(boolean renewal) { + this.renewal = renewal; + } + + public static AutoLoginSuppressionRequest renewal() { + return new AutoLoginSuppressionRequest(true); + } + + public boolean isRenewal() { + return renewal; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index b1f5e649e6d..19360a72493 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -1975,8 +1975,8 @@ static TerminalLogoutState logoutWithoutAutoLoginStep(Runnable requestSuppressio GameState state = Microbot.getClientThread().runOnClientThreadOptional( () -> Microbot.getClient().getGameState()).orElse(GameState.UNKNOWN); if (state != GameState.LOGGED_IN && state != GameState.LOGIN_SCREEN) return TerminalLogoutState.INPUT_BLOCKED; - requestSuppression.run(); if (state == GameState.LOGIN_SCREEN) return TerminalLogoutState.LOGGED_OUT; + requestSuppression.run(); if (Rs2Bank.isOpen()) { return Rs2Widget.clickChildWidget(786434, 11) ? TerminalLogoutState.BANK_CLOSING : TerminalLogoutState.INPUT_BLOCKED; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java index ee166bfdb96..68ae3858811 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -1,54 +1,119 @@ /* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ package net.runelite.client.plugins.microbot.util.player; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.LongSupplier; import java.util.function.Supplier; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; -/** Keeps terminal logout separate from notification; the host pauses gameplay until completed. */ +/** + * Keeps terminal logout separate from notification; the host pauses gameplay until completed. + * Safe to begin or reset from one thread while another thread ticks. + */ public final class TerminalLogoutPrompt { + static final long RETRY_MILLIS = 600L; + static final long RENEW_SUPPRESSION_MILLIS = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS / 3; + + private final LongSupplier clock; private String message; - private long nextAttemptAt; + private long stop; + private boolean attempted; + private long lastAttemptAt; private boolean suppressionRequested; + private long suppressionRequestedAt; + + public TerminalLogoutPrompt() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime())); + } - public void begin(String text) { + TerminalLogoutPrompt(LongSupplier clock) { + this.clock = clock; + } + + public synchronized void begin(String text) { if (message == null) { message = text; - suppressionRequested = false; + startNewStop(); } } - public boolean active() { return message != null; } - public String message() { return message; } - public void reset() { message = null; nextAttemptAt = 0L; suppressionRequested = false; } + public synchronized boolean active() { return message != null; } + public synchronized String message() { return message; } + + public synchronized void reset() { + message = null; + startNewStop(); + } public boolean tick() { - long now = System.currentTimeMillis(); - if (now < nextAttemptAt) return false; - nextAttemptAt = now + 600L; - return tick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, - () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest())); + return throttledTick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, + () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest()), + () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.renewal())); } public boolean tick(Supplier logout, Consumer notify) { - return tick(ignored -> logout.get(), notify, () -> {}); + return tick(ignored -> logout.get(), notify, () -> {}, () -> {}); + } + + boolean throttledTick(Function logout, Consumer notify, + Runnable requestSuppression, Runnable renewSuppression) { + synchronized (this) { + if (message == null) return false; + long now = clock.getAsLong(); + if (attempted && now - lastAttemptAt < RETRY_MILLIS) return false; + attempted = true; + lastAttemptAt = now; + } + return tick(logout, notify, requestSuppression, renewSuppression); } boolean tick(Function logout, Consumer notify, - Runnable requestSuppression) { - if (message == null) return false; - Rs2Player.TerminalLogoutState state = logout.apply(() -> { - if (!suppressionRequested) { - requestSuppression.run(); - suppressionRequested = true; - } - }); + Runnable requestSuppression, Runnable renewSuppression) { + long tickStop; + synchronized (this) { + if (message == null) return false; + tickStop = stop; + } + Rs2Player.TerminalLogoutState state = logout.apply( + () -> suppress(tickStop, requestSuppression, renewSuppression)); if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; - String completedMessage = message; - message = null; + String completedMessage; + synchronized (this) { + if (stop != tickStop || message == null) return false; + completedMessage = message; + message = null; + startNewStop(); + } notify.accept(completedMessage); return true; } + + private void suppress(long tickStop, Runnable requestSuppression, Runnable renewSuppression) { + Runnable action; + synchronized (this) { + if (stop != tickStop) return; + if (!suppressionRequested) { + action = requestSuppression; + } else if (clock.getAsLong() - suppressionRequestedAt >= RENEW_SUPPRESSION_MILLIS) { + action = renewSuppression; + } else { + return; + } + } + action.run(); + synchronized (this) { + if (stop != tickStop) return; + suppressionRequested = true; + suppressionRequestedAt = clock.getAsLong(); + } + } + + private void startNewStop() { + stop++; + attempted = false; + suppressionRequested = false; + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java index be62f005fa4..22c4a66521e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java @@ -14,6 +14,9 @@ public class AutoLoginPluginSupplyStopTest { plugin.accountSelectorScript = mock(AutoLoginScript.class); plugin.onAutoLoginSuppressionRequest(new AutoLoginSuppressionRequest()); verify(plugin.accountSelectorScript).suppressAutoLogin(); + plugin.onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest.renewal()); + verify(plugin.accountSelectorScript).renewAutoLoginSuppression(); + verify(plugin.accountSelectorScript).suppressAutoLogin(); GameStateChanged event = new GameStateChanged(); event.setGameState(GameState.LOGIN_SCREEN); plugin.onGameStateChanged(event); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java index 2a92f48e678..e3558994469 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java @@ -86,4 +86,44 @@ public class AutoLoginSuppressionTest { clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 2); assertFalse(suppression.isSuppressed()); } + + @Test public void renewalRestartsThePendingDeadline() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + suppression.renew(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 2 - 2); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 10); + assertTrue(suppression.isSuppressed()); + } + + @Test public void renewalNeverReblocksAClearedOrExpiredRequest() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.renew(); + assertFalse(suppression.isSuppressed()); + suppression.request(); + suppression.clear(); + suppression.renew(); + assertFalse(suppression.isSuppressed()); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS); + suppression.renew(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void renewalAfterLogoutKeepsWaitingForManualLogin() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + suppression.observe(GameState.LOGIN_SCREEN); + suppression.renew(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 10); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java index 24e0bfb4d51..6a6eb5d4a4f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -3,6 +3,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; import org.junit.Test; import static org.junit.Assert.*; @@ -41,20 +43,99 @@ public class TerminalLogoutPromptTest { assertFalse(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.BANK_CLOSING; - }, messages::add, () -> requests[0]++)); + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); } assertEquals(1, requests[0]); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++)); + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); assertEquals(1, requests[0]); prompt.begin("Second stop"); assertTrue(prompt.tick(request -> { request.run(); return Rs2Player.TerminalLogoutState.LOGGED_OUT; - }, messages::add, () -> requests[0]++)); + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed without delay"); })); assertEquals(2, requests[0]); assertEquals(List.of("First stop", "Second stop"), messages); } + + @Test public void delayedLogoutKeepsRenewingSuppressionUntilTheLoginScreen() { + AtomicLong clock = new AtomicLong(); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + int[] requests = {0}; + long[] lastSuppressionAt = {Long.MIN_VALUE}; + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + long logoutAt = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS * 3; + for (; clock.get() < logoutAt; clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS)) { + assertFalse(prompt.throttledTick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED; + }, messages::add, () -> { + requests[0]++; + lastSuppressionAt[0] = clock.get(); + }, () -> lastSuppressionAt[0] = clock.get())); + assertTrue(clock.get() - lastSuppressionAt[0] < AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS); + } + assertEquals(1, requests[0]); + assertTrue(prompt.throttledTick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add, + () -> requests[0]++, () -> { throw new AssertionError("renewed after logout"); })); + assertTrue(clock.get() - lastSuppressionAt[0] < AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void throttleUsesTheInjectedClockEvenWhenItStartsNegative() { + AtomicLong clock = new AtomicLong(-1_000_000L); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + int[] attempts = {0}; + prompt.begin("Stop"); + Runnable tick = () -> prompt.throttledTick(request -> { + attempts[0]++; + return Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED; + }, text -> {}, () -> {}, () -> {}); + tick.run(); + assertEquals(1, attempts[0]); + clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS - 1); + tick.run(); + assertEquals(1, attempts[0]); + clock.incrementAndGet(); + tick.run(); + assertEquals(2, attempts[0]); + prompt.reset(); + prompt.begin("Next stop"); + tick.run(); + assertEquals(3, attempts[0]); + } + + @Test public void restartFromAnotherThreadDuringLogoutIsNotCompletedOrSuppressedByTheOldTick() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + int[] requests = {0}; + List messages = new ArrayList<>(); + prompt.begin("Old stop"); + assertFalse(prompt.tick(request -> { + Thread restart = new Thread(() -> { + prompt.reset(); + prompt.begin("New stop"); + }); + restart.start(); + try { + restart.join(5_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + assertFalse(restart.isAlive()); + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++, () -> requests[0]++)); + assertEquals(0, requests[0]); + assertTrue(messages.isEmpty()); + assertEquals("New stop", prompt.message()); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++, () -> { throw new AssertionError("renewed a new stop"); })); + assertEquals(1, requests[0]); + assertEquals(List.of("New stop"), messages); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java index 0c0a3fd7659..075fcea47e3 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java @@ -41,8 +41,8 @@ private void check(GameState state, boolean onClientThread, Rs2Player.TerminalLo } } - @Test public void loginScreenConfirmsLogoutWithSuppression() throws Exception { - check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,1); + @Test public void loginScreenConfirmsLogoutWithoutAnUncompletableSuppression() throws Exception { + check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,0); } @Test public void clientThreadCannotDispatchOrRequestSuppression() throws Exception {