diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index b882157b179..a68163fd997 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -272,3 +272,10 @@ An ordinary logout can be a break or world hop, so it must keep normal AutoLogin **Pattern to follow:** The helper arms suppression before closing the bank and reports whether logout was observed or only requested. Uncompleted requests expire after 15 seconds using a monotonic clock, including when no game-state event occurs; observed terminal logouts do not expire. Ordinary `logout()` is unchanged; scripts that use it for terminal failure must explicitly adopt the new API. **Defensive check:** Run `AutoLoginSuppressionTest`, `AutoLoginPluginSupplyStopTest`, `ScriptStartedTest`, `TerminalSupplyLogoutTest`, and `InventorySetupSupplyLogoutTest`. Verify live that a shortage stays logged out and restarting a gameplay script permits login. + + +## Terminal supply stops need observed logout + +An owning script can set `withMissingSupplyHandler` on its Inventory Setup loader to keep its scheduled loop alive while `TerminalLogoutPrompt` closes the bank and requests logout. Do not show a blocking shortage notification or cancel that loop until `LOGGED_OUT` is observed. The default loader policy stays unchanged when no handler is supplied; ordinary breaks and world hops do not arm terminal Auto Login suppression. + +The observed terminal state is `LOGIN_SCREEN`; loading, hopping, connection loss and login transitions are not successful logout. A terminal prompt requests Auto Login suppression once across its retries so an explicit script/Auto Login restart is not repeatedly re-blocked by the old prompt. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index 2ff220dcae8..e7c9906dd4b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -523,6 +523,17 @@ private static boolean isWorldSwitcherLoaded() { () -> Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) != null).orElse(false); } + public static String supplyMessage(String supplies) { + String item = supplies == null ? "required supplies" : supplies.trim(); + item = item.replaceFirst("(?i)^ran out of supplies:\\s*", "") + .replaceFirst("(?i)^(?:out of|missing|no|not enough|insufficient)\\s+", "") + .replaceFirst("(?i)\\s+(?:not found|found|missing)(?: (?:in|from) (?:the )?bank)?[.!]?$", "") + .replaceFirst("(?i)\\s+(?:remaining|in bank)[.!]?$", "") + .replaceFirst("[.!]+$", ""); + if (item.isEmpty()) item = "required supplies"; + return "Ran out of supplies: " + item; + } + public static void showMessage(String message) { try { Runnable messageRunnable = () -> diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index b0a98b7c4a2..e053d4c8c89 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -202,6 +202,13 @@ private static void addRuneLikePouchRows(List out, List missingSupplyHandler; + + /** Retains the owning loop until its terminal logout is observed. */ + public Rs2InventorySetup withMissingSupplyHandler(java.util.function.Consumer handler) { + missingSupplyHandler = Objects.requireNonNull(handler, "handler"); + return this; + } /** * Selects behavior for confirmed required-supply shortages on this loader only. @@ -879,12 +886,13 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { if (!inInventory && !inBank) { int bankGear = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); int invGear = isFuzzy ? Rs2Inventory.itemQuantity((String) identifier) : Rs2Inventory.itemQuantity((int) identifier); + boolean stopLoad = missingSupplyHandler != null || missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP; logSetup(Level.WARN, "missing gear %s | bank=%d | inv=%d | need=%d — %s", item.getName(), bankGear, invGear, item.getQuantity(), - missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP ? "stopping" : "pausing"); + stopLoad ? "stopping" : "pausing"); stopForMissingRequiredSupply(item.getName()); - if (missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP) return false; + if (stopLoad) return false; continue; } @@ -921,6 +929,10 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { } private void stopForMissingRequiredSupply(String itemName) { + if (missingSupplyHandler != null) { + missingSupplyHandler.accept(itemName); + return; + } if (missingSupplyPolicy == MissingSupplyPolicy.PAUSE_ALL_SCRIPTS) { Microbot.pauseAllScripts.compareAndSet(false, true); return; 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 3caa11536b8..4c9f7ed6f99 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 @@ -1963,4 +1963,25 @@ public static Actor getInteracting() { public static boolean hasCompletedTutorialIsland() { return Microbot.getVarbitPlayerValue(281) >= 1000; } + + /** One off-client-thread terminal logout step with AutoLogin suppression. */ + public static TerminalLogoutState logoutWithoutAutoLoginStep() { + return logoutWithoutAutoLoginStep(() -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest())); + } + + static TerminalLogoutState logoutWithoutAutoLoginStep(Runnable requestSuppression) { + if (Microbot.getClientThread().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + 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; + if (Rs2Bank.isOpen()) { + return Rs2Widget.clickChildWidget(786434, 11) + ? TerminalLogoutState.BANK_CLOSING : TerminalLogoutState.INPUT_BLOCKED; + } + logout(); + return TerminalLogoutState.LOGOUT_REQUESTED; + } + } 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 new file mode 100644 index 00000000000..ee166bfdb96 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -0,0 +1,54 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ +package net.runelite.client.plugins.microbot.util.player; + +import java.util.function.Consumer; +import java.util.function.Function; +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. */ +public final class TerminalLogoutPrompt { + private String message; + private long nextAttemptAt; + private boolean suppressionRequested; + + public void begin(String text) { + if (message == null) { + message = text; + suppressionRequested = false; + } + } + + public boolean active() { return message != null; } + public String message() { return message; } + public void reset() { message = null; nextAttemptAt = 0L; suppressionRequested = false; } + + 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())); + } + + public boolean tick(Supplier logout, Consumer notify) { + return tick(ignored -> logout.get(), notify, () -> {}); + } + + boolean tick(Function logout, Consumer notify, + Runnable requestSuppression) { + if (message == null) return false; + Rs2Player.TerminalLogoutState state = logout.apply(() -> { + if (!suppressionRequested) { + requestSuppression.run(); + suppressionRequested = true; + } + }); + if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) return false; + String completedMessage = message; + message = null; + notify.accept(completedMessage); + return true; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java new file mode 100644 index 00000000000..0aab1b913c4 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupTerminalOwnerTest.java @@ -0,0 +1,75 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ +package net.runelite.client.plugins.microbot.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ScheduledFuture; +import java.util.function.Predicate; +import java.util.stream.Stream; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetup; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsItem; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsStackCompareID; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class InventorySetupTerminalOwnerTest { + @Test public void missingSupplyRetainsHostSchedulerForObservedLogout() throws Exception { + ScheduledFuture scheduler = mock(ScheduledFuture.class); + Rs2InventorySetup loader = new Rs2InventorySetup(mock(InventorySetup.class), scheduler); + List missing = new ArrayList<>(); + loader.withMissingSupplyPolicy(Rs2InventorySetup.MissingSupplyPolicy.LOGOUT_AND_STOP) + .withMissingSupplyHandler(missing::add); + var method = Rs2InventorySetup.class.getDeclaredMethod("stopForMissingRequiredSupply", String.class); + method.setAccessible(true); + method.invoke(loader, "Super restore(4)"); + assertEquals(List.of("Super restore(4)"), missing); + verifyNoInteractions(scheduler); + } + + @Test public void handlerWithDefaultPolicyStopsEquipmentLoadAtFirstMissingGear() { + boolean pausedBefore = Microbot.pauseAllScripts.getAndSet(false); + InventorySetup setup = mock(InventorySetup.class); + InventorySetupsItem missingGear = new InventorySetupsItem(100, "Missing helm", 1, false, + InventorySetupsStackCompareID.None, false, -1); + InventorySetupsItem bankedGear = new InventorySetupsItem(200, "Banked body", 1, false, + InventorySetupsStackCompareID.None, false, -1); + when(setup.getInventory()).thenReturn(List.of()); + when(setup.getEquipment()).thenReturn(List.of(missingGear, bankedGear)); + ClientThread thread = mock(ClientThread.class); + when(thread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var inventory = mockStatic(Rs2Inventory.class); + var equipment = mockStatic(Rs2Equipment.class); + var player = mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(mock(Client.class)); + microbot.when(Microbot::getClientThread).thenReturn(thread); + bank.when(Rs2Bank::isOpen).thenReturn(true); + bank.when(() -> Rs2Bank.verifyBankMirrorAfterOpen(true, 0)).thenReturn(true); + bank.when(() -> Rs2Bank.hasItem(200)).thenReturn(true); + inventory.when(Rs2Inventory::items).thenAnswer(call -> Stream.empty()); + inventory.when(() -> Rs2Inventory.items(any(Predicate.class))).thenAnswer(call -> Stream.empty()); + equipment.when(Rs2Equipment::all).thenAnswer(call -> Stream.empty()); + ScheduledFuture scheduler = mock(ScheduledFuture.class); + List missing = new ArrayList<>(); + Rs2InventorySetup loader = new Rs2InventorySetup(setup, scheduler).withMissingSupplyHandler(missing::add); + assertFalse(loader.loadEquipment(false)); + assertEquals(List.of("Missing helm"), missing); + bank.verify(() -> Rs2Bank.withdrawAndEquip(anyInt()), never()); + player.verify(Rs2Player::logoutWithoutAutoLogin, never()); + verify(scheduler, never()).cancel(anyBoolean()); + assertFalse(Microbot.pauseAllScripts.get()); + } finally { + Microbot.pauseAllScripts.set(pausedBefore); + } + } +} 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 new file mode 100644 index 00000000000..24e0bfb4d51 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -0,0 +1,60 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ +package net.runelite.client.plugins.microbot.util.player; + +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class TerminalLogoutPromptTest { + @Test public void waitsThroughBankClosureAndBlockedLogoutBeforeOneNotification() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies: box traps"); + for (Rs2Player.TerminalLogoutState state : new Rs2Player.TerminalLogoutState[]{ + Rs2Player.TerminalLogoutState.BANK_CLOSING, Rs2Player.TerminalLogoutState.INPUT_BLOCKED, + Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED}) { + assertFalse(prompt.tick(() -> state, messages::add)); + assertTrue(prompt.active()); + assertTrue(messages.isEmpty()); + } + assertTrue(prompt.tick(() -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add)); + assertFalse(prompt.active()); + assertEquals(List.of("Ran out of supplies: box traps"), messages); + assertFalse(prompt.tick(() -> { throw new AssertionError("completed stop retried"); }, messages::add)); + } + + @Test public void explicitRestartCancelsOldLogoutAndMessage() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + prompt.begin("Old stop"); + prompt.reset(); + assertFalse(prompt.tick(() -> { throw new AssertionError("old stop continued"); }, + text -> { throw new AssertionError("old popup appeared"); })); + } + + @Test public void suppressionIsRequestedOnceAcrossRetriesAndAgainForANewStop() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + int[] requests = {0}; + List messages = new ArrayList<>(); + prompt.begin("First stop"); + for (int i = 0; i < 3; i++) { + assertFalse(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.BANK_CLOSING; + }, messages::add, () -> requests[0]++)); + } + assertEquals(1, requests[0]); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++)); + assertEquals(1, requests[0]); + prompt.begin("Second stop"); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++)); + assertEquals(2, requests[0]); + assertEquals(List.of("First stop", "Second 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 new file mode 100644 index 00000000000..0c0a3fd7659 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutStepTest.java @@ -0,0 +1,51 @@ +/* Copyright (c) 2026, DRO (droplugins). SPDX-License-Identifier: BSD-2-Clause */ +package net.runelite.client.plugins.microbot.util.player; + +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.concurrent.Callable; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class TerminalLogoutStepTest { + private void check(GameState state, boolean onClientThread, Rs2Player.TerminalLogoutState expected, + int expectedRequests) throws Exception { + Client client=mock(Client.class); + ClientThread thread=mock(ClientThread.class); + when(client.getGameState()).thenReturn(state); + when(thread.isClientThread()).thenReturn(onClientThread); + when(thread.runOnClientThreadOptional(any(Callable.class))).thenAnswer(invocation -> + Optional.ofNullable(((Callable)invocation.getArgument(0)).call())); + Field cf=Microbot.class.getDeclaredField("client"),tf=Microbot.class.getDeclaredField("clientThread"); + cf.setAccessible(true);tf.setAccessible(true); + Object oldClient=cf.get(null),oldThread=tf.get(null); + try { + cf.set(null,client);tf.set(null,thread); + int[] requests={0}; + assertEquals(expected,Rs2Player.logoutWithoutAutoLoginStep(() -> requests[0]++)); + assertEquals(expectedRequests,requests[0]); + } finally { + cf.set(null,oldClient);tf.set(null,oldThread); + } + } + + @Test public void worldAndLoginTransitionsAreNotConfirmedLogout() throws Exception { + for(GameState state:new GameState[]{GameState.HOPPING,GameState.LOADING,GameState.CONNECTION_LOST, + GameState.LOGGING_IN,GameState.LOGIN_SCREEN_AUTHENTICATOR,GameState.UNKNOWN}) { + check(state,false,Rs2Player.TerminalLogoutState.INPUT_BLOCKED,0); + } + } + + @Test public void loginScreenConfirmsLogoutWithSuppression() throws Exception { + check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,1); + } + + @Test public void clientThreadCannotDispatchOrRequestSuppression() throws Exception { + check(GameState.LOGGED_IN,true,Rs2Player.TerminalLogoutState.INPUT_BLOCKED,0); + } +}