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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/entity-guides/items.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ private static void addRuneLikePouchRows(List<InventorySetupsItem> out, List<Inv
public enum MissingSupplyPolicy { PAUSE_ALL_SCRIPTS, LOGOUT_AND_STOP }

private MissingSupplyPolicy missingSupplyPolicy = MissingSupplyPolicy.PAUSE_ALL_SCRIPTS;
private java.util.function.Consumer<String> missingSupplyHandler;

/** Retains the owning loop until its terminal logout is observed. */
public Rs2InventorySetup withMissingSupplyHandler(java.util.function.Consumer<String> handler) {
missingSupplyHandler = Objects.requireNonNull(handler, "handler");
return this;
}

/**
* Selects behavior for confirmed required-supply shortages on this loader only.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -921,6 +929,10 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) {
}

private void stopForMissingRequiredSupply(String itemName) {
if (missingSupplyHandler != null) {
missingSupplyHandler.accept(itemName);
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if (missingSupplyPolicy == MissingSupplyPolicy.PAUSE_ALL_SCRIPTS) {
Microbot.pauseAllScripts.compareAndSet(false, true);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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<Rs2Player.TerminalLogoutState> logout, Consumer<String> notify) {
return tick(ignored -> logout.get(), notify, () -> {});
}

boolean tick(Function<Runnable, Rs2Player.TerminalLogoutState> logout, Consumer<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading