diff --git a/docs/AGENT_SERVER.md b/docs/AGENT_SERVER.md index 65598af260b..a435707f8eb 100644 --- a/docs/AGENT_SERVER.md +++ b/docs/AGENT_SERVER.md @@ -492,6 +492,8 @@ When the client is on the login screen, the response includes `loginIndex` and ` "loggedIn": true, "gameState": "LOGGED_IN", "loginAttemptActive": false, + "hopsAvailable": 372, + "nextHopInMs": 0, "loginDurationMs": 120000, "activeProfile": { "name": "player@email.com", @@ -514,6 +516,8 @@ When the client is on the login screen, the response includes `loginIndex` and ` `loginIndex` and `loginError` are only present when `gameState` is `LOGIN_SCREEN`. When logged in, these fields are omitted. +`hopsAvailable` and `nextHopInMs` come from the world hop limiter (`HopBudget`). They are present whenever "Pace world hops" is enabled in the Microbot settings. `nextHopInMs` is how long `Microbot.hopToWorld` would wait right now, and is 0 while the allowance is above the login reserve. + #### POST /login Triggers a login using the active profile. **Blocks by default** until the login succeeds or fails, returning a definitive result. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90a58f51440..052b89a0177 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,6 +31,7 @@ - `BlockingEventManager` uses exponential backoff when no events validate to reduce overhead; events are re-queued if execution fails. - Default Gradle `test` tasks are disabled in `runelite-client/build.gradle.kts`; use explicit tasks such as `:client:runUnitTests`, `:client:runTests`, `:client:runDebugTests`, `:client:runIntegrationTest`, or `:client:runClientThreadScanner`. - World hopping short-circuits if the player is interacting or already hopping; confirmation widget auto-click handled in `Microbot.hopToWorld`. +- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token, stop waiting if the player is logged out, and press a harmless key so a long wait doesn't trigger the idle logout. The client thread and Swing thread get `false` instead and only check this client's last known state, without touching the file. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, port and a hash of the proxy username, so it survives restarts and is shared by every client on the same exit IP. Writes go to a temporary file that replaces the old one, with `microbot-hop-budget.properties.lock` held while reading and writing. A "Too many login attempts" lockout (login index 33) sets it to zero. ## References - Queryable API guide: `../runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/QUERYABLE_API.md` diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index cd9b345700b..cbf145245c0 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -21,6 +21,8 @@ Scene walk-click render validity and destination confirmation: [movement gotcha Route camera turns only for unreachable targets: [movement gotcha 23](movement.md#23-route-camera-turns-are-for-targets-the-scene-cannot-reach). +Camera turns wait for the yaw to settle before returning: [movement gotcha 24](movement.md#24-a-camera-turn-is-finished-only-when-the-yaw-stops-changing). + ## Format Each entity guide is a numbered list of gotchas. Each entry follows this structure: 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/docs/entity-guides/movement.md b/docs/entity-guides/movement.md index 08e5061e65f..1c2cf04ed37 100644 --- a/docs/entity-guides/movement.md +++ b/docs/entity-guides/movement.md @@ -451,3 +451,33 @@ fallback click toward a nearer visible tile, the walker aligns toward the reques **Defensive check:** `RouteCameraPolicyTest` covers visible, hidden, aligned, recently failed, near, and throttled cases. + +## 24. A camera turn is finished only when the yaw stops changing + +Releasing the arrow key does not stop the camera. The yaw keeps easing for several client +ticks, and any canvas point computed in that window belongs to a view that no longer exists. + +**Why this matters:** In a live probe at the Grand Exchange, the yaw kept changing for +115–126 ms (7 client ticks) after `setAngle` released the key. It drifted another 12.6–17.6 +degrees, and the target object's canvas point moved 56–121 px. Door and transport +interactions click right after `turnTo`, so they could land on a stale position. + +**Where this applies:** `Rs2Camera.setAngle`, `turnTo` and `adjustPitch` all go through +`holdUntil`. After releasing the key, it waits with `sleepUntil` (600 ms cap) until yaw and +pitch read the same on two samples taken on different client ticks. Each sample is read on +the client thread. Callers should not add their own sleeps after `turnTo`. + +The settle check cannot tell our own coasting apart from anything else that moves the camera. +While `NpcTracker` is steering the yaw with `setCameraYawTarget`, the QoL plugin's smooth camera +tracking is on, or the user is dragging the camera, the yaw never holds still for two ticks +and every turn waits the full 600 ms cap before returning. That is the bound working, not a +hang; the view is still moving when the call returns, so a canvas point taken right after it +can still be stale. + +If the client-thread read fails (thread interrupted or the client-thread timeout), `getYaw` and +`getPitch` fall back to reading the camera directly instead of returning 0. A yaw of 0 is +north and a pitch of 0 sends `adjustPitch` holding UP, so a fake 0 looked like a real angle. +`adjustPitch` returns without pressing a key when the pitch is already at the target. + +**Defensive check:** `Rs2CameraSettleTest` simulates a camera that coasts after release. It +also covers the timeout bound and the client-thread no-op. diff --git a/gradle.properties b/gradle.properties index 3a10760e10a..472b0f88d62 100644 --- a/gradle.properties +++ b/gradle.properties @@ -32,7 +32,7 @@ project.build.version=1.13.1 runelite.injected-client.version=1.13.1 glslang.path= -microbot.version=2.6.31 +microbot.version=2.6.32 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username= diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java index 8a738ccaee2..d7e19d3e1ad 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java @@ -59,6 +59,7 @@ public BlockingEventManager() blockingEvents.add(new BankJagexPopupEvent()); blockingEvents.add(new PluginPauseEvent()); blockingEvents.add(new EnjoyRSChatboxEvent()); + blockingEvents.add(new MembershipPromptEvent()); blockingEvents.add(new DisableWorldSwitcherConfirmationEvent()); blockingEvents.add(new HideRoofsEvent()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java index 89e0cb7e8fd..f018f6071f0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/GameChatAppender.java @@ -1,13 +1,16 @@ package net.runelite.client.plugins.microbot; import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.AppenderBase; import ch.qos.logback.core.Context; import ch.qos.logback.core.filter.Filter; import ch.qos.logback.core.spi.FilterReply; +import java.util.function.BooleanSupplier; import net.runelite.api.ChatMessageType; +import net.runelite.client.util.ResetResistantAttachment; public class GameChatAppender extends AppenderBase { private final PatternLayout layout = new PatternLayout(); @@ -28,6 +31,15 @@ public GameChatAppender() { this("[%d{HH:mm:ss}] %msg%ex{0}%n"); // Default simple pattern } + public ResetResistantAttachment install(LoggerContext context, BooleanSupplier enabled) { + setContext(context); + Runnable startIfEnabled = () -> { + if (enabled.getAsBoolean()) start(); + }; + startIfEnabled.run(); + return ResetResistantAttachment.attach(context, this, startIfEnabled); + } + @Override public void setContext(Context context) { super.setContext(context); 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 d8b98fdfee2..79d5594b75c 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 @@ -12,8 +12,10 @@ import net.runelite.api.annotations.Component; import net.runelite.api.annotations.Varbit; import net.runelite.api.annotations.Varp; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.ItemContainerChanged; import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.VarClientID; import net.runelite.api.widgets.Widget; import net.runelite.api.widgets.WidgetModalMode; import net.runelite.client.Notifier; @@ -42,10 +44,12 @@ import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.item.Rs2ItemManager; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; import net.runelite.client.plugins.microbot.util.mouse.Mouse; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; import net.runelite.client.plugins.microbot.util.mouse.naturalmouse.NaturalMouse; import net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache; @@ -55,6 +59,7 @@ import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; import net.runelite.client.plugins.microbot.api.tileitem.Rs2TileItemCache; import net.runelite.client.plugins.microbot.api.tileobject.Rs2TileObjectCache; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.ui.overlay.infobox.InfoBoxManager; @@ -69,6 +74,7 @@ import javax.swing.Timer; import javax.swing.*; import java.awt.*; +import java.awt.event.KeyEvent; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; @@ -173,6 +179,8 @@ public class Microbot { private static net.runelite.api.World quickHopTargetWorld; private static final int WORLD_SWITCHER_LOAD_TIMEOUT_MS = 5000; private static final int WORLD_SWITCHER_REOPEN_INTERVAL_MS = 600; + private static final int HOP_WAIT_MIN_IDLE_MARGIN_TICKS = 3000; + private static final int HOP_WAIT_MAX_IDLE_MARGIN_TICKS = 10000; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -194,7 +202,8 @@ public class Microbot { private static GameState lastGameState = null; public static boolean cantReachTarget = false; - public static boolean cantHopWorld = false; + public static volatile boolean cantHopWorld = false; + private static volatile WorldPoint cantHopWorldLocation = null; public static int cantReachTargetRetries = 0; @@ -372,18 +381,25 @@ public static boolean hopToWorld(int worldNumber) { log.error("Already hopping world"); return true; } - if (Microbot.cantHopWorld) { - log.error("Can't hop world, already trying to hop"); - return false; + if (Microbot.getClient().getWorld() != worldNumber) { + if (isNonMembersHopRefused(worldNumber)) { + log.error("Can't hop to non-members world {} from a members-only area", worldNumber); + return false; + } + String blocker = Microbot.getClientThread().runOnClientThreadOptional(Microbot::hopBlocker).orElse(null); + if (blocker != null) { + log.error(blocker); + return false; + } + if (!awaitHopBudget(worldNumber)) { + return false; + } } final net.runelite.api.World[] target = new net.runelite.api.World[1]; boolean prepared = Microbot.getClientThread().runOnClientThreadOptional(() -> { - if (Rs2Combat.inCombat()) { - log.error("Player is in combat, cannot hop worlds"); - return false; - } - if (Rs2Bank.isOpen() || Rs2Shop.isOpen() || Rs2Dialogue.isInDialogue()) { - log.error("Blocking widget open (bank/shop/dialogue), cannot hop worlds"); + String blocker = hopBlocker(); + if (blocker != null) { + log.error(blocker); return false; } if (quickHopTargetWorld != null || Microbot.getClient().getGameState() != GameState.LOGGED_IN) { @@ -464,6 +480,92 @@ public static boolean hopToWorld(int worldNumber) { return success; } + public static void markNonMembersHopRefused(WorldPoint location) { + cantHopWorldLocation = location; + cantHopWorld = true; + } + + static boolean isNonMembersHopRefused(int worldNumber) { + if (!cantHopWorld) { + return false; + } + WorldPoint refusedAt = cantHopWorldLocation; + if (refusedAt == null || !refusedAt.equals(Rs2Player.getWorldLocation())) { + cantHopWorld = false; + cantHopWorldLocation = null; + return false; + } + WorldService service = getWorldService(); + net.runelite.http.api.worlds.WorldResult worlds = service == null ? null : service.getWorlds(); + World world = worlds == null ? null : worlds.findWorld(worldNumber); + return world != null && !world.getTypes().contains(net.runelite.http.api.worlds.WorldType.MEMBERS); + } + + private static String hopBlocker() { + if (Rs2Combat.inCombat()) { + return "Player is in combat, cannot hop worlds"; + } + if (Rs2Bank.isOpen() || Rs2Shop.isOpen() || Rs2Dialogue.isInDialogue()) { + return "Blocking widget open (bank/shop/dialogue), cannot hop worlds"; + } + return null; + } + + static boolean awaitHopBudget(int worldNumber) { + HopBudget budget = HopBudget.getInstance(); + if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { + long waitMs = budget.cachedMillisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } + log.warn("Hop limit reached - not hopping to world {}, next hop allowed in {}s", + worldNumber, TimeUnit.MILLISECONDS.toSeconds(waitMs)); + return false; + } + long waitMs = budget.millisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } + log.info("Hop limit reached - waiting {}s before hopping to world {}", + TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); + String previousStatus = status; + int idleMargin = Rs2Random.between(HOP_WAIT_MIN_IDLE_MARGIN_TICKS, HOP_WAIT_MAX_IDLE_MARGIN_TICKS); + try { + while (waitMs > 0) { + if (!isLoggedIn()) { + log.info("Logged out while waiting for the hop limit - not hopping to world {}", worldNumber); + return false; + } + if (shouldPreventIdleLogout(idleMargin)) { + Rs2Keyboard.keyPress(KeyEvent.VK_BACK_SPACE); + idleMargin = Rs2Random.between(HOP_WAIT_MIN_IDLE_MARGIN_TICKS, HOP_WAIT_MAX_IDLE_MARGIN_TICKS); + } + status = "Hop limit - next hop in " + TimeUnit.MILLISECONDS.toSeconds(waitMs + 999) + "s"; + Thread.sleep(Math.min(waitMs, 1000L)); + waitMs = budget.millisUntilHopAllowed(); + } + return true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } finally { + status = previousStatus; + } + } + + private static boolean shouldPreventIdleLogout(int idleMargin) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Client client = Microbot.getClient(); + return Rs2Player.checkIdleLogout(idleMargin) + && isNullOrEmpty(client.getVarcStrValue(VarClientID.CHATINPUT)) + && isNullOrEmpty(client.getVarcStrValue(VarClientID.MESLAYERINPUT)); + }).orElse(false); + } + + private static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { if (!isWorldSwitcherLoaded() && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { @@ -490,6 +592,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/MicrobotConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java index dbf20321206..1070bb0023f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotConfig.java @@ -7,6 +7,8 @@ import net.runelite.client.config.ConfigGroup; import net.runelite.client.config.ConfigItem; import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Range; +import net.runelite.client.plugins.microbot.util.security.HopBudget; @ConfigGroup(MicrobotConfig.configGroup) public interface MicrobotConfig extends Config @@ -272,4 +274,67 @@ default int inputIdleResumeMs() { return 1800; } + + @ConfigSection( + name = "World hop limit", + description = "Pace world hops so the per-IP login limit (\"Too many login attempts\") is never reached", + position = 4 + ) + String hopLimitSection = "hopLimitSection"; + + String keyEnableHopLimit = "enableHopLimit"; + @ConfigItem( + keyName = keyEnableHopLimit, + name = "Pace world hops", + description = "Wait before a scripted world hop when the estimated hop allowance runs low, instead of getting locked out. " + + "Shared by every client on the same IP or proxy.", + position = 0, + section = hopLimitSection + ) + default boolean enableHopLimit() + { + return true; + } + + String keyHopLimitCapacity = "hopLimitCapacity"; + @Range(min = 1, max = 1000) + @ConfigItem( + keyName = keyHopLimitCapacity, + name = "Hop allowance", + description = "Estimated number of hops and logins Jagex allows before locking the IP out", + position = 1, + section = hopLimitSection + ) + default int hopLimitCapacity() + { + return HopBudget.DEFAULT_CAPACITY; + } + + String keyHopLimitRefillPerHour = "hopLimitRefillPerHour"; + @Range(min = 1, max = 1000) + @ConfigItem( + keyName = keyHopLimitRefillPerHour, + name = "Hops regained per hour", + description = "Estimated number of hops the allowance regains every hour", + position = 2, + section = hopLimitSection + ) + default int hopLimitRefillPerHour() + { + return HopBudget.DEFAULT_REFILL_PER_HOUR; + } + + String keyHopLimitReserve = "hopLimitReserve"; + @Range(min = 0, max = 200) + @ConfigItem( + keyName = keyHopLimitReserve, + name = "Reserve for logins", + description = "Hops never spent on world hopping, kept for logging back in after breaks or disconnects", + position = 3, + section = hopLimitSection + ) + default int hopLimitReserve() + { + return HopBudget.DEFAULT_RESERVE; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 1ff435b816e..9f25c41883e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -6,6 +6,7 @@ import net.runelite.api.*; import net.runelite.api.events.*; import net.runelite.api.gameval.InventoryID; +import net.runelite.client.RuneLite; import net.runelite.client.RuneLiteProperties; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.EventBus; @@ -40,6 +41,7 @@ import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.NavigationButton; @@ -47,8 +49,8 @@ import net.runelite.client.ui.overlay.OverlayManager; import net.runelite.client.ui.overlay.OverlayMenuEntry; import net.runelite.client.util.ImageUtil; +import net.runelite.client.util.ResetResistantAttachment; -import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Inject; @@ -58,6 +60,7 @@ import java.awt.AWTException; import java.awt.Rectangle; import java.awt.image.BufferedImage; +import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -66,6 +69,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; @PluginDescriptor( name = PluginDescriptor.Default + "Microbot", description = "Microbot", @@ -101,6 +105,9 @@ public class MicrobotPlugin extends Plugin @Inject private MicrobotConfig microbotConfig; + @Inject + private ScheduledExecutorService executor; + private MicrobotTopLevelConfigPanel topLevelConfigPanel; private NavigationButton navButton; @@ -123,6 +130,8 @@ MicrobotConfig provideConfig(ConfigManager configManager) @Inject private EventBus eventBus; private GameChatAppender gameChatAppender; + private ResetResistantAttachment gameChatAttachment; + private ResetResistantAttachment scriptErrorAttachment; @Inject private MicrobotVersionChecker microbotVersionChecker; @@ -154,18 +163,9 @@ protected void startUp() throws AWTException gameChatAppender.setPattern(pattern); final LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); - gameChatAppender.setContext(context); - context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(gameChatAppender); - - scriptErrorReporter.setContext(context); - scriptErrorReporter.start(); - context.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(scriptErrorReporter); + gameChatAttachment = gameChatAppender.install(context, microbotConfig::enableGameChatLogging); + scriptErrorAttachment = scriptErrorReporter.install(context); - // Start appender if logging is enabled - if (microbotConfig.enableGameChatLogging()) { - gameChatAppender.start(); - } - // Initialize the cached configuration in GameChatAppender GameChatAppender.updateConfiguration( microbotConfig.enableGameChatLogging(), @@ -177,6 +177,9 @@ protected void startUp() throws AWTException InputArbiter.setDisabled(microbotConfig.disableInputYielding()); InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); + applyHopBudgetConfig(); + executor.execute(() -> HopBudget.getInstance().persistTo( + new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey())); CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); @@ -225,9 +228,8 @@ protected void shutDown() overlayManager.remove(gembagOverlay); overlayManager.remove(pouchOverlay); clientToolbar.removeNavigation(navButton); - if (gameChatAppender.isStarted()) gameChatAppender.stop(); - ((LoggerContext) LoggerFactory.getILoggerFactory()).getLogger(Logger.ROOT_LOGGER_NAME).detachAppender(scriptErrorReporter); - scriptErrorReporter.stop(); + gameChatAttachment.remove(); + scriptErrorAttachment.remove(); microbotVersionChecker.shutdown(); } @@ -323,7 +325,10 @@ private int[] getShopContainerIds() @Subscribe public void onGameStateChanged(GameStateChanged gameStateChanged) { - + if (isLoginServerRequest(LoginManager.getLastKnownGameState(), gameStateChanged.getGameState())) + { + executor.execute(() -> HopBudget.getInstance().recordHop()); + } if (gameStateChanged.getGameState() == GameState.LOGGED_IN) { // Region-based login detection logic @@ -359,6 +364,26 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) LoginManager.setLastKnownGameState(gameStateChanged.getGameState()); } + static boolean isLoginServerRequest(GameState previous, GameState next) + { + if (next == GameState.HOPPING) + { + return previous != GameState.HOPPING; + } + return next == GameState.LOGGING_IN + && previous != GameState.HOPPING + && previous != GameState.LOGGING_IN; + } + + private void applyHopBudgetConfig() + { + HopBudget.getInstance().configure( + microbotConfig.enableHopLimit(), + microbotConfig.hopLimitCapacity(), + microbotConfig.hopLimitRefillPerHour(), + microbotConfig.hopLimitReserve()); + } + private static EnumSet normalizeWorldTypesForProfileComparison(EnumSet rawTypes) { EnumSet normalized = rawTypes == null @@ -463,7 +488,7 @@ private void onChatMessage(ChatMessage event) String msg = event.getMessage(); if (msg != null && containsIgnoreCase(msg, "you can't log into a non-members")) { - Microbot.cantHopWorld = true; + Microbot.markNonMembersHopRefused(Rs2Player.getWorldLocation()); } // Leagues: "haven't unlocked access to X area" -> blacklist last transport dest. @@ -518,6 +543,12 @@ public void onConfigChanged(ConfigChanged ev) case MicrobotConfig.keyInputIdleResumeMs: InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); break; + case MicrobotConfig.keyEnableHopLimit: + case MicrobotConfig.keyHopLimitCapacity: + case MicrobotConfig.keyHopLimitRefillPerHour: + case MicrobotConfig.keyHopLimitReserve: + applyHopBudgetConfig(); + break; case MicrobotConfig.keyEnableGameChatLogging: case MicrobotConfig.keyGameChatLogPattern: case MicrobotConfig.keyGameChatLogLevel: 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..50b4c984d79 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 @@ -23,7 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", - version = "1.0.6", + version = "1.0.7", tags = {"account", "microbot", "login"}, enabledByDefault = false ) @@ -51,7 +51,13 @@ protected void shutDown() { @Subscribe public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { - accountSelectorScript.suppressAutoLogin(); + if (event.isRenewal()) { + accountSelectorScript.renewAutoLoginSuppression(); + } else if (event.isLoggedOut()) { + accountSelectorScript.holdAutoLoginLoggedOut(); + } 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..97c53740b27 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,14 @@ void suppressAutoLogin() { suppression.request(); } + void renewAutoLoginSuppression() { + suppression.renew(); + } + + void holdAutoLoginLoggedOut() { + suppression.holdLoggedOut(LoginManager.getGameState()); + } + 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..52d0095cea7 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,19 @@ synchronized void request() { } } + synchronized void renew() { + expirePendingRequest(); + if (state == State.AWAITING_LOGOUT) { + requestedAt = clock.getAsLong(); + } + } + + synchronized void holdLoggedOut(GameState gameState) { + if (gameState == GameState.LOGIN_SCREEN) { + state = State.LOGGED_OUT; + } + } + 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..3e6dacbcaf0 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 @@ -6,4 +6,33 @@ * Ordinary logouts must not post this event. */ public final class AutoLoginSuppressionRequest { + public static final long PENDING_LOGOUT_MILLIS = 15_000L; + + private enum Kind { REQUEST, RENEWAL, LOGGED_OUT } + + private final Kind kind; + + public AutoLoginSuppressionRequest() { + this(Kind.REQUEST); + } + + private AutoLoginSuppressionRequest(Kind kind) { + this.kind = kind; + } + + public static AutoLoginSuppressionRequest renewal() { + return new AutoLoginSuppressionRequest(Kind.RENEWAL); + } + + public static AutoLoginSuppressionRequest loggedOut() { + return new AutoLoginSuppressionRequest(Kind.LOGGED_OUT); + } + + public boolean isRenewal() { + return kind == Kind.RENEWAL; + } + + public boolean isLoggedOut() { + return kind == Kind.LOGGED_OUT; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java index eeab31b4a88..252b1c10cec 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/LoginHandler.java @@ -7,6 +7,7 @@ import net.runelite.api.GameState; import net.runelite.client.config.ConfigProfile; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; @@ -79,6 +80,11 @@ private Map buildStatusMap() { if (loginCooldownMs > 0) { result.put("loginCooldownMs", loginCooldownMs); } + HopBudget hopBudget = HopBudget.getInstance(); + if (hopBudget.isEnabled()) { + result.put("hopsAvailable", hopBudget.available()); + result.put("nextHopInMs", hopBudget.millisUntilHopAllowed()); + } if (Microbot.isLoggedIn()) { long durationMs = LoginManager.getLoginDuration().toMillis(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java index b328274e18f..09997c7786e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporter.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.diagnostics; import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.StackTraceElementProxy; @@ -28,6 +29,7 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.MicrobotApi; import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.util.ResetResistantAttachment; @Slf4j @Singleton @@ -59,9 +61,20 @@ public class ScriptErrorReporter extends UnsynchronizedAppenderBase 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/camera/Rs2Camera.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java index 11be71ee6a4..103f72d4385 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/camera/Rs2Camera.java @@ -148,7 +148,7 @@ public static void adjustPitch(float percentage) { if (currentPitchPercentage < percentage) { holdUntil(KeyEvent.VK_UP, () -> cameraPitchPercentage() >= percentage); - } else { + } else if (currentPitchPercentage > percentage) { holdUntil(KeyEvent.VK_DOWN, () -> cameraPitchPercentage() <= percentage); } } @@ -165,10 +165,39 @@ private static void holdUntil(int keyCode, java.util.function.BooleanSupplier re } finally { Rs2Keyboard.keyRelease(keyCode); } + if (!awaitCameraSettled()) { + log.debug("Camera did not settle within {}ms after releasing key {}", SETTLE_TIMEOUT_MS, keyCode); + } + } + + static final int SETTLE_TIMEOUT_MS = 600; + + static boolean awaitCameraSettled() { + int[][] previous = new int[1][]; + return Global.sleepUntil(() -> { + int[] sample = sampleCamera(); + if (sample == null) { + return false; + } + int[] last = previous[0]; + previous[0] = sample; + return last != null && sample[0] != last[0] && sample[1] == last[1] && sample[2] == last[2]; + }, SETTLE_TIMEOUT_MS); + } + + private static int[] sampleCamera() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Client client = Microbot.getClient(); + return new int[]{client.getGameCycle(), client.getCameraYaw(), client.getCameraPitch()}; + }).orElse(null); } public static int getPitch() { - return fromClientAngleUnits(Microbot.getClient().getCameraPitch()); + return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraPitch()).orElseGet(Rs2Camera::readPitchDirectly)); + } + + private static int readPitchDirectly() { + return Microbot.getClient().getCameraPitch(); } // set camera pitch @@ -283,7 +312,11 @@ public static void setZoom(int zoom) { } // Get camera/compass facing public static int getYaw() { - return fromClientAngleUnits(Microbot.getClient().getCameraYaw()); + return fromClientAngleUnits(Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getCameraYaw()).orElseGet(Rs2Camera::readYawDirectly)); + } + + private static int readYawDirectly() { + return Microbot.getClient().getCameraYaw(); } // Set camera/compass facing diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java new file mode 100644 index 00000000000..b3be87e3ca5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEvent.java @@ -0,0 +1,56 @@ +package net.runelite.client.plugins.microbot.util.events; + +import net.runelite.api.Client; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.BlockingEvent; +import net.runelite.client.plugins.microbot.BlockingEventPriority; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +public class MembershipPromptEvent implements BlockingEvent { + + static final int[][] PROMPTS = { + {InterfaceID.F2PBondRedeem.UNIVERSE, InterfaceID.F2PBondRedeem.CLOSE}, + {InterfaceID.BondPrompt.UNIVERSE, InterfaceID.BondPrompt.CLOSE}, + {InterfaceID.MembershipBenefitsPromptHop.UNIVERSE, InterfaceID.MembershipBenefitsPromptHop.CLOSE_BUTTON}, + }; + + @Override + public boolean validate() { + return findCloseButton() != null; + } + + @Override + public boolean execute() { + Widget closeButton = findCloseButton(); + if (closeButton == null) return true; + + Rs2Widget.clickWidget(closeButton); + return Global.sleepUntil(() -> findCloseButton() == null, 10000); + } + + @Override + public BlockingEventPriority priority() { + return BlockingEventPriority.NORMAL; + } + + private static Widget findCloseButton() { + if (!Microbot.isLoggedIn()) return null; + return Microbot.getClientThread() + .runOnClientThreadOptional(() -> findCloseButton(Microbot.getClient())) + .orElse(null); + } + + static Widget findCloseButton(Client client) { + if (client == null) return null; + for (int[] prompt : PROMPTS) { + Widget root = client.getWidget(prompt[0]); + if (root == null || root.isHidden()) continue; + Widget close = client.getWidget(prompt[1]); + if (close != null && !close.isHidden()) return close; + } + return null; + } +} 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..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 @@ -1705,12 +1705,13 @@ public static int distanceTo(WorldPoint endpoint) { * @return {@code true} if the player's idle time exceeds or equals the adjusted timeout, {@code false} otherwise. */ public static boolean checkIdleLogout(long randomDelay) { - long idleClientTicks = Long.min( - Microbot.getClient().getMouseIdleTicks(), - Microbot.getClient().getKeyboardIdleTicks() - ); - - return idleClientTicks >= Microbot.getClient().getIdleTimeout() - randomDelay; + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + long idleClientTicks = Long.min( + Microbot.getClient().getMouseIdleTicks(), + Microbot.getClient().getKeyboardIdleTicks() + ); + return idleClientTicks >= Microbot.getClient().getIdleTimeout() - randomDelay; + }).orElse(false); } /** @@ -1963,4 +1964,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; + 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; + } + 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..b9c8eaeec06 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPrompt.java @@ -0,0 +1,130 @@ +/* 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. */ +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 stop; + private boolean attempted; + private long lastAttemptAt; + private boolean suppressionRequested; + private long suppressionRequestedAt; + + public TerminalLogoutPrompt() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime())); + } + + TerminalLogoutPrompt(LongSupplier clock) { + this.clock = clock; + } + + public synchronized void begin(String text) { + if (message == null) { + message = text; + startNewStop(); + } + } + + public synchronized boolean active() { return message != null; } + public synchronized String message() { return message; } + + public synchronized void reset() { + message = null; + startNewStop(); + } + + public boolean tick() { + return throttledTick(Rs2Player::logoutWithoutAutoLoginStep, Microbot::showMessage, + () -> Microbot.getEventBus().post(new AutoLoginSuppressionRequest()), + () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.renewal()), + () -> Microbot.getEventBus().post(AutoLoginSuppressionRequest.loggedOut())); + } + + public boolean tick(Supplier logout, Consumer notify) { + return tick(ignored -> logout.get(), notify, () -> {}, () -> {}, () -> {}); + } + + boolean throttledTick(Function logout, Consumer notify, + Runnable requestSuppression, Runnable renewSuppression, Runnable holdLoggedOut) { + 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, holdLoggedOut); + } + + boolean tick(Function logout, Consumer notify, + Runnable requestSuppression, Runnable renewSuppression, Runnable holdLoggedOut) { + long tickStop; + synchronized (this) { + if (message == null) return false; + tickStop = stop; + } + Rs2Player.TerminalLogoutState state = logout.apply( + () -> requestSuppressionOnce(tickStop, requestSuppression, renewSuppression)); + if (state != Rs2Player.TerminalLogoutState.LOGGED_OUT) { + renewSuppressionIfDue(tickStop, renewSuppression); + return false; + } + boolean neverSuppressed; + synchronized (this) { + if (stop != tickStop || message == null) return false; + neverSuppressed = !suppressionRequested; + } + if (neverSuppressed) holdLoggedOut.run(); + String completedMessage; + synchronized (this) { + if (stop != tickStop || message == null) return false; + completedMessage = message; + message = null; + startNewStop(); + } + notify.accept(completedMessage); + return true; + } + + private void requestSuppressionOnce(long tickStop, Runnable requestSuppression, Runnable renewSuppression) { + synchronized (this) { + if (stop != tickStop || suppressionRequested) return; + } + requestSuppression.run(); + renewSuppression.run(); + markSuppressed(tickStop); + } + + private void renewSuppressionIfDue(long tickStop, Runnable renewSuppression) { + synchronized (this) { + if (stop != tickStop || !suppressionRequested + || clock.getAsLong() - suppressionRequestedAt < RENEW_SUPPRESSION_MILLIS) return; + } + renewSuppression.run(); + markSuppressed(tickStop); + } + + private synchronized void markSuppressed(long tickStop) { + if (stop != tickStop) return; + suppressionRequested = true; + suppressionRequestedAt = clock.getAsLong(); + } + + private void startNewStop() { + stop++; + attempted = false; + suppressionRequested = false; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java new file mode 100644 index 00000000000..5eff0e7e990 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -0,0 +1,301 @@ +package net.runelite.client.plugins.microbot.util.security; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.proxy.ProxyConfiguration; + +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import java.util.function.UnaryOperator; + +@Slf4j +public final class HopBudget { + public static final int DEFAULT_CAPACITY = 390; + public static final int DEFAULT_REFILL_PER_HOUR = 93; + public static final int DEFAULT_RESERVE = 20; + public static final String FILE_NAME = "microbot-hop-budget.properties"; + + private static final HopBudget INSTANCE = new HopBudget(System::currentTimeMillis, new MemoryStore()); + + private final LongSupplier clock; + private volatile Store store; + private volatile boolean enabled = true; + private volatile int capacity = DEFAULT_CAPACITY; + private volatile int refillPerHour = DEFAULT_REFILL_PER_HOUR; + private volatile int reserve = DEFAULT_RESERVE; + + HopBudget(LongSupplier clock, Store store) { + this.clock = clock; + this.store = store; + } + + public static HopBudget getInstance() { + return INSTANCE; + } + + public static String networkKey() { + return networkKey(System.getProperty("socksProxyHost"), System.getProperty("socksProxyPort"), + ProxyConfiguration.getUsername()); + } + + static String networkKey(String host, String port, String username) { + if (host == null || host.isBlank()) { + return "direct"; + } + StringBuilder key = new StringBuilder("proxy.").append(sanitize(host)); + if (port != null && !port.isBlank()) { + key.append('_').append(sanitize(port)); + } + if (username != null && !username.isEmpty()) { + key.append('.').append(digest(username)); + } + return key.toString(); + } + + private static String digest(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (int i = 0; i < 8; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private static String sanitize(String value) { + return value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + } + + public void persistTo(Path file, String networkKey) { + store = new FileStore(file, networkKey, store); + current(); + } + + public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { + this.capacity = Math.max(1, capacity); + this.refillPerHour = Math.max(1, refillPerHour); + this.reserve = Math.min(Math.max(0, reserve), this.capacity - 1); + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + public int available() { + return (int) Math.floor(current().tokens); + } + + public long millisUntilHopAllowed() { + return enabled ? millisUntil(current()) : 0L; + } + + public long cachedMillisUntilHopAllowed() { + return enabled ? millisUntil(refill(store.peek(), clock.getAsLong())) : 0L; + } + + private long millisUntil(State state) { + double missing = reserve + 1 - state.tokens; + if (missing <= 0) { + return 0L; + } + return Math.max(1L, (long) Math.ceil(missing / refillPerMilli())); + } + + public void recordHop() { + long now = clock.getAsLong(); + State next = store.update(state -> { + State refilled = refill(state, now); + return new State(Math.max(0, refilled.tokens - 1), now); + }); + if (enabled && next.tokens < reserve + 1) { + log.info("Hop limit nearly reached - {} hops left, keeping {} in reserve for logins", + (int) Math.floor(next.tokens), reserve); + } + } + + public void recordLockout() { + long now = clock.getAsLong(); + store.update(state -> new State(0, now)); + } + + private State current() { + long now = clock.getAsLong(); + return refill(store.update(state -> state != null && state.updatedAt > now + ? new State(state.tokens, now) : state), now); + } + + private State refill(State state, long now) { + if (state == null) { + return new State(capacity, now); + } + long elapsed = Math.max(0L, now - state.updatedAt); + double tokens = Math.min(capacity, state.tokens + elapsed * refillPerMilli()); + return new State(tokens, now); + } + + private double refillPerMilli() { + return refillPerHour / (double) TimeUnit.HOURS.toMillis(1); + } + + static final class State { + final double tokens; + final long updatedAt; + + State(double tokens, long updatedAt) { + this.tokens = tokens; + this.updatedAt = updatedAt; + } + } + + interface Store { + State update(UnaryOperator change); + + State peek(); + } + + static final class MemoryStore implements Store { + private State state; + + MemoryStore() { + } + + MemoryStore(State state) { + this.state = state; + } + + @Override + public synchronized State update(UnaryOperator change) { + state = change.apply(state); + return state; + } + + @Override + public synchronized State peek() { + return state; + } + } + + static final class FileStore implements Store { + private final Path file; + private final Path lockFile; + private final String tokensKey; + private final String updatedAtKey; + private final MemoryStore fallback; + private boolean warned; + + FileStore(Path file, String networkKey, Store seed) { + this.file = file; + this.lockFile = file.resolveSibling(file.getFileName() + ".lock"); + this.tokensKey = networkKey + ".tokens"; + this.updatedAtKey = networkKey + ".updatedAt"; + this.fallback = new MemoryStore(seed.peek()); + } + + @Override + public synchronized State update(UnaryOperator change) { + boolean interrupted = Thread.interrupted(); + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, + StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Properties properties = read(); + State current = parse(properties); + State next = change.apply(current); + if (next != current && next != null) { + properties.setProperty(tokensKey, Double.toString(next.tokens)); + properties.setProperty(updatedAtKey, Long.toString(next.updatedAt)); + write(properties); + } + fallback.update(state -> next); + return next; + } + } catch (IOException | RuntimeException e) { + if (!warned) { + warned = true; + log.warn("Hop budget file {} unavailable, tracking hops for this client only", file, e); + } + return fallback.update(change); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + @Override + public State peek() { + return fallback.peek(); + } + + private State parse(Properties properties) { + String tokens = properties.getProperty(tokensKey); + String updatedAt = properties.getProperty(updatedAtKey); + if (tokens == null || updatedAt == null) { + return null; + } + try { + double parsedTokens = Double.parseDouble(tokens); + if (!Double.isFinite(parsedTokens) || parsedTokens < 0) { + return null; + } + return new State(parsedTokens, Long.parseLong(updatedAt)); + } catch (NumberFormatException e) { + return null; + } + } + + private Properties read() throws IOException { + Properties properties = new Properties(); + byte[] bytes; + try { + bytes = Files.readAllBytes(file); + } catch (NoSuchFileException e) { + return properties; + } + properties.load(new StringReader(new String(bytes, StandardCharsets.UTF_8))); + return properties; + } + + private void write(Properties properties) throws IOException { + StringWriter writer = new StringWriter(); + properties.store(writer, null); + ByteBuffer buffer = ByteBuffer.wrap(writer.toString().getBytes(StandardCharsets.UTF_8)); + Path temp = Files.createTempFile(file.toAbsolutePath().getParent(), file.getFileName().toString(), ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(false); + } + try { + Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temp); + } + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java index da9f6b9ac83..12d048a073a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginManager.java @@ -243,6 +243,7 @@ public static boolean checkLoginLockout() { return lockout.isActive(); } if (lockout.recordLockout()) { + HopBudget.getInstance().recordLockout(); log.warn("Too many login attempts - pausing all logins for {}s (lockout #{})", TimeUnit.MILLISECONDS.toSeconds(lockout.remainingMillis()), lockout.getConsecutiveLockouts()); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 0d50ea21e69..dae623eafd1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -7684,11 +7684,9 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar WalkerState state = walkWithStateInternal(target, distance); if (state == WalkerState.ARRIVED) { WebWalkLog.bankWalkDebug("arrived goal={}", target); - } else { + } else if (state != WalkerState.MOVING) { WebWalkLog.bankWalkFailed(target, state); setTarget(null, "rs2walker:walkWithBankedTransports:direct-walk-failed"); - return state; - } return state; } else { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java index e076effb721..8137bf22497 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtil.java @@ -388,8 +388,8 @@ public static boolean hopWorld(CompletableFuture scheduledFuture, int world, log.info("Attempting world hop {} to world {}", attemptsPerWorld, world); - ; - sleepUntil(() -> Microbot.hopToWorld(world), 6000); + sleepUntil(Microbot::isLoggedIn, 6000); + Microbot.hopToWorld(world); sleepUntil(() -> !Microbot.isHopping(), 6000); if (Microbot.isHopping()) { Microbot.status = "Failed to hop to world " + world + " (attempt " + attemptsPerWorld + ")"; diff --git a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java index a2c97757e4d..6aa909a2b3b 100644 --- a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java +++ b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java @@ -15,6 +15,12 @@ */ public class ProxyConfiguration { + private static volatile String username; + + public static String getUsername() { + return username; + } + /** * Sets up the proxy configuration based on the provided options. * @param options @@ -44,6 +50,7 @@ public static void setupProxy(OptionSet options, ArgumentAcceptingOptionSpec it.navBtn == navBtn); - sidebar.remove(navBtn.getPanel().getWrappedPanel()); - if (closingOpenTab) + updatingNavigation = true; + try { - HistoryEntry entry = selectedTabHistory.isEmpty() - ? new HistoryEntry(true, null) - : selectedTabHistory.removeLast(); + sidebar.remove(navBtn.getPanel().getWrappedPanel()); + if (closingOpenTab) + { + HistoryEntry entry = selectedTabHistory.isEmpty() + ? new HistoryEntry(true, null) + : selectedTabHistory.removeLast(); - openPanel(entry.navBtn, entry.sidebarOpen); + openPanel(entry.navBtn, closing ? sidebar.isVisible() : entry.sidebarOpen); + if (!closing) + { + saveSidebarTab(); + } + } + } + finally + { + updatingNavigation = false; } } sidebarEntries.remove(navBtn); } + @Subscribe(priority = 100) + private void onClientShutdown(ClientShutdown event) + { + closing = true; + } + @Subscribe private void onGameStateChanged(final GameStateChanged event) { @@ -420,6 +460,11 @@ public void componentMoved(ComponentEvent e) selectedTab = newSelectedTab; + if (!updatingNavigation) + { + saveSidebarTab(); + } + if (sidebar.isVisible()) { pushHistory(); @@ -630,6 +675,12 @@ else if (OSType.getOSType() == OSType.MacOS && SystemInfo.isMacFullWindowContent // Update config updateFrameConfig(false); + // Reopen the console if it was open when the client was last closed + if (configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN, Boolean.class) == Boolean.TRUE) + { + setConsoleVisible(true); + } + // Close sidebar if the config closed state is set if (configManager.getConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_CLOSED, Boolean.class) == Boolean.TRUE) { @@ -715,6 +766,7 @@ public void show() frame.revalidateMinimumSize(); // this must run after the native window border is installed on the window frame.updateContainsInScreen(); + sidebarTabRestored = true; }); // Show out of date dialog if needed @@ -1076,6 +1128,19 @@ public GraphicsConfiguration getGraphicsConfiguration() return frame.getGraphicsConfiguration(); } + private void saveSidebarTab() + { + sidebarTabRestored = true; + if (selectedTab == null) + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB); + } + else + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_SIDEBAR_TAB, selectedTab.getTooltip()); + } + } + void openPanel(NavigationButton navBtn, boolean showSidebar) { if (navBtn != null && !sidebarEntries.contains(navBtn)) @@ -1187,6 +1252,14 @@ private void setConsoleVisible(boolean visible) } consoleVisible = visible; + if (visible) + { + configManager.setConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN, true); + } + else + { + configManager.unsetConfiguration(CONFIG_GROUP, CONFIG_CLIENT_CONSOLE_OPEN); + } clientPanel.setConsoleVisible(visible); updateConsoleToggleButton(); @@ -1237,12 +1310,7 @@ private void initializeConsoleLogging() System.setOut(consolePrintStream); LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - consoleLogAppender = new ConsoleLogAppender(consolePanel::append); - consoleLogAppender.setContext(loggerContext); - consoleLogAppender.start(); - - Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME); - rootLogger.addAppender(consoleLogAppender); + consoleLogAppender = ConsoleLogAppender.install(loggerContext, originalOut, consolePanel::append); } private BufferedImage createConsoleIcon(boolean active) diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java index 95be2c60434..392fd5c61ef 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ConsoleLogAppender.java @@ -4,19 +4,87 @@ */ package net.runelite.client.ui; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.PatternLayout; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.ConsoleTarget; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; import java.util.function.Consumer; +import net.runelite.client.util.ResetResistantAttachment; final class ConsoleLogAppender extends AppenderBase { + static final int MAX_REBIND_ATTEMPTS = 1000; + private final Consumer logConsumer; + private final OutputStream stdout; private PatternLayout layout; + private ResetResistantAttachment attachment; + private int rebindAttemptsLeft; ConsoleLogAppender(Consumer logConsumer) + { + this(logConsumer, null); + } + + private ConsoleLogAppender(Consumer logConsumer, OutputStream stdout) { this.logConsumer = logConsumer; + this.stdout = stdout; + } + + static ConsoleLogAppender install(LoggerContext context, OutputStream stdout, Consumer logConsumer) + { + ConsoleLogAppender appender = new ConsoleLogAppender(logConsumer, stdout); + appender.setContext(context); + bindStdoutAppenders(context, stdout); + appender.start(); + appender.attachment = ResetResistantAttachment.attach(context, appender, appender::restartAfterReset); + return appender; + } + + void uninstall() + { + attachment.remove(); + } + + static boolean bindStdoutAppenders(LoggerContext context, OutputStream stdout) + { + OutputStream shielded = new CloseShieldOutputStream(stdout); + boolean bound = false; + for (Logger logger : context.getLoggerList()) + { + Iterator> appenders = logger.iteratorForAppenders(); + while (appenders.hasNext()) + { + Appender appender = appenders.next(); + if (appender instanceof ConsoleAppender + && ConsoleTarget.SystemOut.getName().equals(((ConsoleAppender) appender).getTarget())) + { + ((ConsoleAppender) appender).setOutputStream(shielded); + bound = true; + } + } + } + return bound; + } + + private synchronized void restartAfterReset() + { + rebindAttemptsLeft = stdout != null ? MAX_REBIND_ATTEMPTS : 0; + start(); + } + + synchronized boolean isRebindPending() + { + return rebindAttemptsLeft > 0; } @Override @@ -36,7 +104,31 @@ protected void append(ILoggingEvent eventObject) { return; } + if (rebindAttemptsLeft > 0) + { + rebindAttemptsLeft = bindStdoutAppenders((LoggerContext) getContext(), stdout) ? 0 : rebindAttemptsLeft - 1; + } String formatted = layout.doLayout(eventObject); logConsumer.accept(formatted); } + + private static final class CloseShieldOutputStream extends FilterOutputStream + { + private CloseShieldOutputStream(OutputStream out) + { + super(out); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException + { + out.write(b, off, len); + } + + @Override + public void close() throws IOException + { + flush(); + } + } } diff --git a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java index f6fe67040d3..c9519eb6c8d 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/LogConsolePanel.java @@ -5,12 +5,21 @@ package net.runelite.client.ui; import java.awt.*; +import java.awt.event.HierarchyEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.awt.geom.Rectangle2D; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import javax.swing.JPanel; +import javax.swing.JScrollBar; import javax.swing.JScrollPane; +import javax.swing.JViewport; import javax.swing.JTextArea; import javax.swing.SwingUtilities; import javax.swing.border.MatteBorder; @@ -22,9 +31,23 @@ final class LogConsolePanel extends JPanel { private static final int MAX_CHARACTERS = 100_000; + // Trimming shifts the view, so it is deferred while the user is scrolled up - but not forever. + private static final int MAX_CHARACTERS_SCROLLED_UP = 400_000; private static final int PREFERRED_HEIGHT = 160; + private static final int BOTTOM_TOLERANCE_PX = 4; + // enough passes for a starved layout to catch up, few enough to give up on an off-screen console + private static final int MAX_SNAP_ATTEMPTS = 20; private final JTextArea textArea = new JTextArea(); + private final JScrollPane scrollPane = new JScrollPane(textArea); + + private final StringBuilder pending = new StringBuilder(); + private boolean flushScheduled; + + private boolean followTail = true; + private boolean snapQueued; + private int snapAttempts; + private int tailCaretDot = -1; LogConsolePanel() { @@ -41,14 +64,168 @@ final class LogConsolePanel extends JPanel textArea.setForeground(new Color(0, 255, 70)); textArea.setBorder(null); - DefaultCaret caret = (DefaultCaret) textArea.getCaret(); - caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE); + DefaultCaret caret = new DefaultCaret() + { + @Override + protected void adjustVisibility(Rectangle nloc) + { + if (getDot() != tailCaretDot || getMark() != tailCaretDot) + { + super.adjustVisibility(nloc); + } + } + }; + caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); + textArea.setCaret(caret); - JScrollPane scrollPane = new JScrollPane(textArea); scrollPane.setBorder(null); add(scrollPane, BorderLayout.CENTER); + + // Following is decided by what the user does, never by measuring the view: under a flood the + // scroll position trails the document, which reads as "the user scrolled up". + scrollPane.addMouseWheelListener(ev -> + { + if (ev.getPreciseWheelRotation() < 0) + { + followTail = false; + } + reconsiderFollowingTail(); + }); + + textArea.addKeyListener(new KeyAdapter() + { + @Override + public void keyPressed(KeyEvent ev) + { + followTail = false; + SwingUtilities.invokeLater(LogConsolePanel.this::reconsiderFollowingTail); + } + }); + textArea.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent ev) + { + followTail = false; + } + + @Override + public void mouseReleased(MouseEvent ev) + { + reconsiderFollowingTail(); + } + }); + textArea.addMouseMotionListener(new MouseMotionAdapter() + { + @Override + public void mouseDragged(MouseEvent ev) + { + followTail = false; + } + }); + + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent ev) + { + followTail = false; + } + + @Override + public void mouseReleased(MouseEvent ev) + { + reconsiderFollowingTail(); + } + }); + vertical.addMouseMotionListener(new MouseMotionAdapter() + { + @Override + public void mouseDragged(MouseEvent ev) + { + followTail = false; + } + }); + + // opening the console lands on the newest line, wherever the view was left + addHierarchyListener(ev -> + { + if ((ev.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && isShowing()) + { + scrollToBottom(); + } + }); + } + + void scrollToBottom() + { + followTail = true; + snapAttempts = 0; + queueSnapToBottom(); + } + + // Snapping waits for the next pass: until the insert's revalidation has run the viewport still + // reports the pre-insert view size and would snap a line short of the newest one. + private void queueSnapToBottom() + { + if (snapQueued) + { + return; + } + + snapQueued = true; + SwingUtilities.invokeLater(() -> + { + snapQueued = false; + if (followTail) + { + snapToBottom(); + } + }); + } + + private void snapToBottom() + { + int end = textArea.getDocument().getLength(); + moveCaretToTail(); + + Rectangle2D newestLine; + try + { + // the viewport's laid-out size trails the document, so ask the text view, which reads the + // newest line's position from the document itself + newestLine = textArea.modelToView2D(end); + } + catch (BadLocationException ex) + { + return; + } + + if (newestLine == null) + { + return; + } + + textArea.scrollRectToVisible(newestLine.getBounds()); + + JViewport viewport = scrollPane.getViewport(); + + // The scroll is clamped to the size the text area was last laid out at, so under load it can + // land short, and nothing else would correct that - keep trying while the layout catches up. + boolean reachedNewestLine = newestLine.getMaxY() + <= viewport.getViewPosition().y + viewport.getExtentSize().height + 1; + if (reachedNewestLine || ++snapAttempts > MAX_SNAP_ATTEMPTS) + { + snapAttempts = 0; + return; + } + + queueSnapToBottom(); } + // One batch per event queue pass. A line per pass is what breaks the console under load: each + // insert schedules its own layout, and a busy script logs faster than the queue can run them. void append(String text) { if (text == null || text.isEmpty()) @@ -56,19 +233,52 @@ void append(String text) return; } - String sanitized = text.replace("\r", ""); - if (SwingUtilities.isEventDispatchThread()) + synchronized (pending) { - appendOnEdt(sanitized); + pending.append(text, 0, text.length()); + + if (pending.length() > 2 * MAX_CHARACTERS_SCROLLED_UP) + { + // the oldest buffered lines would be trimmed out of the document on arrival anyway + pending.delete(0, pending.length() - MAX_CHARACTERS_SCROLLED_UP); + } + + if (flushScheduled) + { + return; + } + flushScheduled = true; } - else + + SwingUtilities.invokeLater(this::flushPending); + } + + private void flushPending() + { + String text; + synchronized (pending) { - SwingUtilities.invokeLater(() -> appendOnEdt(sanitized)); + flushScheduled = false; + if (pending.length() == 0) + { + return; + } + + int cap = followTail ? MAX_CHARACTERS : MAX_CHARACTERS_SCROLLED_UP; + text = pending.substring(Math.max(0, pending.length() - cap)); + pending.setLength(0); } + + appendOnEdt(text.replace("\r", "")); } void clear() { + synchronized (pending) + { + pending.setLength(0); + } + if (SwingUtilities.isEventDispatchThread()) { textArea.setText(""); @@ -89,10 +299,18 @@ private void appendOnEdt(String text) Document document = textArea.getDocument(); try { + boolean atBottom = followTail; int length = document.getLength(); document.insertString(length, text, null); - trimIfNecessary(); - textArea.setCaretPosition(document.getLength()); + trimIfNecessary(atBottom); + if (atBottom) + { + moveCaretToTail(); + // NEVER_UPDATE stops the caret dragging the view, so move it ourselves; the attempt + // budget is per batch, or a flood that outruns the layout would exhaust it for good. + snapAttempts = 0; + queueSnapToBottom(); + } } catch (BadLocationException ex) { @@ -100,10 +318,36 @@ private void appendOnEdt(String text) } } - private void trimIfNecessary() + private void moveCaretToTail() + { + tailCaretDot = textArea.getDocument().getLength(); + textArea.setCaretPosition(tailCaretDot); + } + + // Deferred so the user's gesture has reached the scrollbar before it is read. + private void reconsiderFollowingTail() + { + SwingUtilities.invokeLater(() -> + { + followTail = isScrolledToBottom() && textArea.getSelectionStart() == textArea.getSelectionEnd(); + if (followTail) + { + snapAttempts = 0; + queueSnapToBottom(); + } + }); + } + + private boolean isScrolledToBottom() + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + return vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - BOTTOM_TOLERANCE_PX; + } + + private void trimIfNecessary(boolean atBottom) { Document document = textArea.getDocument(); - int excess = document.getLength() - MAX_CHARACTERS; + int excess = document.getLength() - (atBottom ? MAX_CHARACTERS : MAX_CHARACTERS_SCROLLED_UP); if (excess <= 0) { return; diff --git a/runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java b/runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java new file mode 100644 index 00000000000..7bcff0e9717 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/util/ResetResistantAttachment.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 Microbot Contributors + * All rights reserved. + */ +package net.runelite.client.util; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.status.ErrorStatus; + +public final class ResetResistantAttachment implements LoggerContextListener +{ + private final LoggerContext context; + private final Appender appender; + private final Runnable onReset; + private volatile boolean removed; + + private ResetResistantAttachment(LoggerContext context, Appender appender, Runnable onReset) + { + this.context = context; + this.appender = appender; + this.onReset = onReset; + } + + public static ResetResistantAttachment attach(LoggerContext context, Appender appender, Runnable onReset) + { + ResetResistantAttachment attachment = new ResetResistantAttachment(context, appender, onReset); + context.addListener(attachment); + root(context).addAppender(appender); + return attachment; + } + + public void remove() + { + removed = true; + context.removeListener(this); + root(context).detachAppender(appender); + appender.stop(); + } + + @Override + public boolean isResetResistant() + { + return true; + } + + @Override + public void onStart(LoggerContext context) + { + } + + @Override + public void onReset(LoggerContext context) + { + if (removed) + { + return; + } + try + { + onReset.run(); + } + catch (RuntimeException e) + { + context.getStatusManager().add(new ErrorStatus("Failed to restart appender " + appender.getName() + " after reset", this, e)); + } + root(context).addAppender(appender); + } + + @Override + public void onStop(LoggerContext context) + { + removed = true; + root(context).detachAppender(appender); + appender.stop(); + } + + @Override + public void onLevelChange(Logger logger, Level level) + { + } + + private static Logger root(LoggerContext context) + { + return context.getLogger(Logger.ROOT_LOGGER_NAME); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java index 9e6f49b6ed3..2dc9262d50f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/GameChatAppenderTest.java @@ -10,10 +10,12 @@ import net.runelite.api.Client; import net.runelite.api.GameState; import net.runelite.client.callback.ClientThread; +import net.runelite.client.util.ResetResistantAttachment; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -21,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -100,6 +103,41 @@ public void filteredEventDoesNotTouchClientThread() verify(client, never()).addChatMessage(any(), anyString(), anyString(), anyString(), anyBoolean()); } + @Test + public void installedAppenderKeepsMirroringToChatAfterLogbackReset() + { + LoggerContext context = new LoggerContext(); + GameChatAppender installed = new GameChatAppender(); + ResetResistantAttachment attachment = installed.install(context, () -> true); + + context.reset(); + context.getLogger("net.runelite.client.plugins.microbot.walker").warn("walker stalled"); + + assertTrue(installed.isStarted()); + verify(clientThread, times(1)).invoke(any(Runnable.class)); + + attachment.remove(); + context.getLogger("net.runelite.client.plugins.microbot.walker").warn("after remove"); + + assertFalse(installed.isStarted()); + verify(clientThread, times(1)).invoke(any(Runnable.class)); + } + + @Test + public void disabledAppenderStaysStoppedAfterLogbackReset() + { + LoggerContext context = new LoggerContext(); + GameChatAppender installed = new GameChatAppender(); + installed.install(context, () -> false); + + context.reset(); + context.getLogger("net.runelite.client.plugins.microbot.walker").warn("walker stalled"); + + assertFalse(installed.isStarted()); + verify(clientThread, never()).invoke(any(Runnable.class)); + context.stop(); + } + private static Object swapStatic(String name, Object value) throws Exception { Field field = Microbot.class.getDeclaredField(name); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java index 930770f6712..1d04928de5c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotConfigTest.java @@ -1,7 +1,9 @@ package net.runelite.client.plugins.microbot; +import net.runelite.client.plugins.microbot.util.security.HopBudget; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class MicrobotConfigTest @@ -13,4 +15,15 @@ public void inputYieldingIsDisabledByDefault() assertTrue(config.disableInputYielding()); } + + @Test + public void hopLimitIsOnByDefaultWithTheEstimatedAllowance() + { + MicrobotConfig config = new MicrobotConfig() { }; + + assertTrue(config.enableHopLimit()); + assertEquals(HopBudget.DEFAULT_CAPACITY, config.hopLimitCapacity()); + assertEquals(HopBudget.DEFAULT_REFILL_PER_HOUR, config.hopLimitRefillPerHour()); + assertEquals(HopBudget.DEFAULT_RESERVE, config.hopLimitReserve()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java new file mode 100644 index 00000000000..a7fa7c4b5a5 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotPluginHopCountTest.java @@ -0,0 +1,31 @@ +package net.runelite.client.plugins.microbot; + +import net.runelite.api.GameState; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class MicrobotPluginHopCountTest +{ + @Test + public void countsEachHopAndEachLoginAttemptOnce() + { + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.HOPPING)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.LOGIN_SCREEN_AUTHENTICATOR, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.UNKNOWN, GameState.LOGGING_IN)); + assertTrue(MicrobotPlugin.isLoginServerRequest(GameState.CONNECTION_LOST, GameState.LOGGING_IN)); + } + + @Test + public void ignoresTransitionsThatDoNotReachTheLoginServer() + { + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.HOPPING)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.LOGGING_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOGGING_IN, GameState.LOGGING_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.HOPPING, GameState.LOADING)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOADING, GameState.LOGGED_IN)); + assertFalse(MicrobotPlugin.isLoginServerRequest(GameState.LOGGED_IN, GameState.LOGIN_SCREEN)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java index 924e6d5976d..d997e7e6160 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java @@ -2,9 +2,17 @@ import net.runelite.api.Client; import net.runelite.api.World; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.callback.ClientThread; +import net.runelite.client.game.WorldService; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.combat.Rs2Combat; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.security.HopBudget; +import net.runelite.http.api.worlds.WorldResult; +import net.runelite.http.api.worlds.WorldType; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -12,6 +20,7 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.util.EnumSet; import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.atomic.AtomicBoolean; @@ -68,9 +77,102 @@ public void setUp() { @After public void tearDown() { + Microbot.cantHopWorld = false; microbot.close(); } + private static final WorldPoint REFUSED_AT = new WorldPoint(3221, 3218, 0); + + private void stubWorlds() { + net.runelite.http.api.worlds.World f2p = mock(net.runelite.http.api.worlds.World.class); + when(f2p.getTypes()).thenReturn(EnumSet.noneOf(WorldType.class)); + net.runelite.http.api.worlds.World members = mock(net.runelite.http.api.worlds.World.class); + when(members.getTypes()).thenReturn(EnumSet.of(WorldType.MEMBERS)); + WorldResult result = mock(WorldResult.class); + when(result.findWorld(308)).thenReturn(f2p); + when(result.findWorld(302)).thenReturn(members); + WorldService worldService = mock(WorldService.class); + when(worldService.getWorlds()).thenReturn(result); + microbot.when(Microbot::getWorldService).thenReturn(worldService); + } + + @Test + public void membersAreaRefusalBlocksNonMembersHopFromTheSameTile() { + stubWorlds(); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class); + MockedStatic hopBudget = Mockito.mockStatic(HopBudget.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + + assertFalse(Microbot.hopToWorld(308)); + + hopBudget.verify(HopBudget::getInstance, never()); + } + assertTrue(Microbot.cantHopWorld); + verify(client, never()).hopToWorld(any()); + } + + @Test + public void membersAreaRefusalDoesNotBlockMembersWorlds() { + stubWorlds(); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + + assertFalse(Microbot.isNonMembersHopRefused(302)); + assertTrue(Microbot.isNonMembersHopRefused(308)); + } + } + + @Test + public void membersAreaRefusalClearsOnceThePlayerHasMoved() { + stubWorlds(); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT.dx(1)); + + assertFalse(Microbot.isNonMembersHopRefused(308)); + assertFalse(Microbot.cantHopWorld); + + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + assertFalse(Microbot.isNonMembersHopRefused(308)); + } + } + + @Test + public void flagWithoutARefusalLocationDoesNotBlockHops() { + stubWorlds(); + Microbot.markNonMembersHopRefused(null); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(REFUSED_AT); + + assertFalse(Microbot.isNonMembersHopRefused(308)); + assertFalse(Microbot.cantHopWorld); + } + } + + @Test + public void staleRefusalDoesNotStopLaterHopAttempts() { + stubWorlds(); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + Microbot.markNonMembersHopRefused(REFUSED_AT); + try (MockedStatic player = Mockito.mockStatic(Rs2Player.class); + MockedStatic combat = Mockito.mockStatic(Rs2Combat.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class)) { + player.when(Rs2Player::getWorldLocation).thenReturn(new WorldPoint(3165, 3487, 0)); + combat.when(Rs2Combat::inCombat).thenReturn(false); + bank.when(Rs2Bank::isOpen).thenReturn(true); + + assertFalse(Microbot.hopToWorld(308)); + + combat.verify(Rs2Combat::inCombat, atLeastOnce()); + } + assertFalse(Microbot.cantHopWorld); + } + @Test public void opensSwitcherThenHopsOnceListHasLoaded() { doAnswer(inv -> { @@ -103,6 +205,26 @@ public void hopsImmediatelyWhenSwitcherAlreadyOpen() { verify(client).hopToWorld(target); } + @Test + public void blockedHopIsRefusedBeforeWaitingForTheHopLimit() { + HopBudget budget = mock(HopBudget.class); + when(budget.millisUntilHopAllowed()).thenThrow(new AssertionError("waited for the hop limit")); + when(budget.cachedMillisUntilHopAllowed()).thenThrow(new AssertionError("waited for the hop limit")); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + when(client.getWorld()).thenReturn(301); + try (MockedStatic hopBudget = Mockito.mockStatic(HopBudget.class); + MockedStatic combat = Mockito.mockStatic(Rs2Combat.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class)) { + hopBudget.when(HopBudget::getInstance).thenReturn(budget); + combat.when(Rs2Combat::inCombat).thenReturn(false); + bank.when(Rs2Bank::isOpen).thenReturn(true); + + assertFalse(Microbot.hopToWorld(302)); + } + + verify(client, never()).hopToWorld(any()); + } + @Test public void doesNotHopWhenSwitcherNeverLoads() { assertFalse(Microbot.hopViaWorldSwitcher(target, 300)); 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..410e1c38d69 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,13 @@ 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(); + plugin.onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest.loggedOut()); + verify(plugin.accountSelectorScript).holdAutoLoginLoggedOut(); + verify(plugin.accountSelectorScript).suppressAutoLogin(); + verify(plugin.accountSelectorScript).renewAutoLoginSuppression(); 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..eea76cb8430 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,80 @@ 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()); + } + + @Test public void holdAtTheLoginScreenStaysSuppressedUntilManualLogin() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.holdLoggedOut(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 100); + suppression.observe(GameState.LOGGING_IN); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } + + @Test public void holdIsClearedByARestart() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.holdLoggedOut(GameState.LOGIN_SCREEN); + suppression.clear(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void holdOutsideTheLoginScreenDoesNothing() { + for (GameState state : new GameState[] {GameState.LOGGED_IN, GameState.LOADING, GameState.HOPPING, + GameState.CONNECTION_LOST, GameState.LOGGING_IN, GameState.UNKNOWN}) { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.holdLoggedOut(state); + assertFalse(state.name(), suppression.isSuppressed()); + } + } + + @Test public void holdCompletesAPendingRequestFromAnotherSource() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + suppression.holdLoggedOut(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 10); + assertTrue(suppression.isSuppressed()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java index 4b3cf86854e..032d9fc9bb1 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/diagnostics/ScriptErrorReporterTest.java @@ -13,15 +13,19 @@ import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.MicrobotApi; import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.util.ResetResistantAttachment; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -249,4 +253,49 @@ public void sendsNothingWithDisableTelemetryFlag() assertEquals(0, reporter.pendingCount()); verify(api, never()).submitErrors(any(), any()); } + + @Test + public void installedReporterKeepsCollectingErrorsAfterLogbackReset() + { + ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + ScriptErrorReporter installed = new ScriptErrorReporter(api, mock(MicrobotPluginManager.class), executor, sessions, false); + ResetResistantAttachment attachment = installed.install(context); + + context.reset(); + context.getLogger("some.Script").error("loop failed", boom()); + + assertTrue(installed.isStarted()); + assertEquals(1, installed.pendingCount()); + verify(executor, times(2)).scheduleWithFixedDelay(any(), anyLong(), anyLong(), any()); + attachment.remove(); + } + + @Test + public void reinstalledReporterCountsEachErrorOnceAfterLogbackReset() + { + ScriptErrorReporter installed = new ScriptErrorReporter(api, mock(MicrobotPluginManager.class), mock(ScheduledExecutorService.class), sessions, false); + installed.install(context).remove(); + installed.install(context); + + context.reset(); + context.getLogger("some.Script").error("loop failed", boom()); + installed.flush(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JsonObject.class); + verify(api).submitErrors(captor.capture(), any()); + assertEquals(1, captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject().get("count").getAsInt()); + } + + @Test + public void removedReporterIgnoresErrorsAfterLogbackReset() + { + ScriptErrorReporter installed = new ScriptErrorReporter(api, mock(MicrobotPluginManager.class), mock(ScheduledExecutorService.class), sessions, false); + installed.install(context).remove(); + + context.reset(); + context.getLogger("some.Script").error("loop failed", boom()); + + assertFalse(installed.isStarted()); + assertEquals(0, installed.pendingCount()); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java new file mode 100644 index 00000000000..30ce3b49601 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScriptReachedDistanceTest.java @@ -0,0 +1,69 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.WalkerState; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ShortestPathScriptReachedDistanceTest +{ + private static final WorldPoint TARGET = new WorldPoint(3210, 3424, 0); + + @Test + public void bankedManualWalkUsesConfiguredFinishDistance() throws Exception + { + ShortestPathConfig config = mock(ShortestPathConfig.class); + when(config.walkWithBankedTransports()).thenReturn(true); + when(config.reachedDistance()).thenReturn(3); + ShortestPathScript script = scriptWith(config); + + try (MockedStatic walker = Mockito.mockStatic(Rs2Walker.class)) + { + walker.when(() -> Rs2Walker.walkWithBankedTransportsAndState(eq(TARGET), anyInt(), anyBoolean())) + .thenReturn(WalkerState.ARRIVED); + + assertEquals(WalkerState.ARRIVED, script.executeWalk(TARGET)); + + walker.verify(() -> Rs2Walker.walkWithBankedTransportsAndState(TARGET, 3, false)); + } + } + + @Test + public void plainManualWalkStillUsesTheWalkerDefaultDistance() throws Exception + { + ShortestPathConfig config = mock(ShortestPathConfig.class); + when(config.walkWithBankedTransports()).thenReturn(false); + ShortestPathScript script = scriptWith(config); + + try (MockedStatic walker = Mockito.mockStatic(Rs2Walker.class)) + { + walker.when(() -> Rs2Walker.walkWithState(TARGET)).thenReturn(WalkerState.MOVING); + + assertEquals(WalkerState.MOVING, script.executeWalk(TARGET)); + + walker.verify(() -> Rs2Walker.walkWithState(TARGET)); + walker.verify(() -> Rs2Walker.walkWithBankedTransportsAndState(Mockito.any(), anyInt(), anyBoolean()), + Mockito.never()); + } + } + + private static ShortestPathScript scriptWith(ShortestPathConfig config) throws Exception + { + ShortestPathScript script = new ShortestPathScript(ignored -> {}); + Field field = ShortestPathScript.class.getDeclaredField("config"); + field.setAccessible(true); + field.set(script, config); + return script; + } +} 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/camera/Rs2CameraSettleTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java new file mode 100644 index 00000000000..9732b41646a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/camera/Rs2CameraSettleTest.java @@ -0,0 +1,174 @@ +package net.runelite.client.plugins.microbot.util.camera; + +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.awt.event.KeyEvent; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Rs2CameraSettleTest { + + private static final int FULL_TURN = 16384; + private static final int TICK_MS = 20; + private static final int HELD_SPEED = 160; + private static final int COAST_DECAY = 20; + private static final int MAX_POLL_MS = 320; + private static final int SCHEDULING_SLACK_MS = 1000; + + private MockedStatic microbot; + private MockedStatic keyboard; + private ScheduledExecutorService ticker; + private Client client; + private ClientThread clientThread; + + private final AtomicInteger cycle = new AtomicInteger(); + private final AtomicInteger yaw = new AtomicInteger(); + private final AtomicInteger pitch = new AtomicInteger(2048); + private final AtomicInteger velocity = new AtomicInteger(); + private final AtomicInteger heldKey = new AtomicInteger(-1); + private final AtomicBoolean spinForever = new AtomicBoolean(); + private final AtomicBoolean onClientThread = new AtomicBoolean(); + private final AtomicBoolean isClientThread = new AtomicBoolean(); + + @Before + public void setUp() { + client = mock(Client.class); + when(client.isClientThread()).thenAnswer(inv -> isClientThread.get()); + when(client.getCameraYaw()).thenAnswer(inv -> onClientThread(yaw)); + when(client.getCameraPitch()).thenAnswer(inv -> onClientThread(pitch)); + when(client.getGameCycle()).thenAnswer(inv -> onClientThread(cycle)); + + clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + onClientThread.set(true); + try { + return Optional.ofNullable(callable.call()); + } finally { + onClientThread.set(false); + } + }); + + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + keyboard = Mockito.mockStatic(Rs2Keyboard.class); + keyboard.when(() -> Rs2Keyboard.keyHold(anyInt())).thenAnswer(inv -> { + heldKey.set(inv.getArgument(0)); + return null; + }); + keyboard.when(() -> Rs2Keyboard.keyRelease(anyInt())).thenAnswer(inv -> { + heldKey.compareAndSet(inv.getArgument(0), -1); + return null; + }); + + ticker = Executors.newSingleThreadScheduledExecutor(); + ticker.scheduleAtFixedRate(this::tick, TICK_MS, TICK_MS, TimeUnit.MILLISECONDS); + } + + @After + public void tearDown() { + ticker.shutdownNow(); + keyboard.close(); + microbot.close(); + } + + private int onClientThread(AtomicInteger value) { + if (!onClientThread.get()) { + throw new IllegalStateException("camera state must be read on the client thread"); + } + return value.get(); + } + + private void tick() { + int key = heldKey.get(); + int v; + if (spinForever.get()) { + v = HELD_SPEED; + } else if (key == KeyEvent.VK_LEFT) { + v = -HELD_SPEED; + } else if (key == KeyEvent.VK_RIGHT) { + v = HELD_SPEED; + } else { + int current = velocity.get(); + v = current > 0 ? Math.max(0, current - COAST_DECAY) : Math.min(0, current + COAST_DECAY); + } + velocity.set(v); + yaw.set(Math.floorMod(yaw.get() + v, FULL_TURN)); + cycle.incrementAndGet(); + } + + @Test + public void setAngleReturnsOnlyAfterYawStopsCoasting() throws Exception { + Rs2Camera.setAngle(90, 10); + + int yawAtReturn = yaw.get(); + assertEquals("camera must be at rest when setAngle returns", 0, velocity.get()); + Thread.sleep(200); + assertEquals("yaw kept changing after setAngle returned", yawAtReturn, yaw.get()); + assertTrue("turn did not reach the target window", Rs2Camera.isAngleGood(90, 40)); + } + + @Test + public void settleWaitIsBoundedWhenCameraNeverStops() { + spinForever.set(true); + + long start = System.nanoTime(); + boolean settled = Rs2Camera.awaitCameraSettled(); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + assertFalse(settled); + assertTrue("settle wait ran " + elapsedMs + "ms", elapsedMs < Rs2Camera.SETTLE_TIMEOUT_MS + MAX_POLL_MS + SCHEDULING_SLACK_MS); + } + + @Test + public void failedClientThreadReadFallsBackToTheLiveCameraAngles() { + doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + doReturn(Rs2Camera.toClientAngleUnits(1536)).when(client).getCameraYaw(); + doReturn(Rs2Camera.toClientAngleUnits(383)).when(client).getCameraPitch(); + + assertEquals(1536, Rs2Camera.getYaw()); + assertEquals(383, Rs2Camera.getPitch()); + } + + @Test + public void adjustPitchLeavesTheKeysAloneWhenAlreadyAtTarget() { + Rs2Camera.adjustPitch(Rs2Camera.cameraPitchPercentage()); + + keyboard.verify(() -> Rs2Keyboard.keyHold(anyInt()), never()); + keyboard.verify(() -> Rs2Keyboard.keyRelease(anyInt()), never()); + } + + @Test + public void settleWaitNeverRunsOnTheClientThread() { + isClientThread.set(true); + + assertFalse(Rs2Camera.awaitCameraSettled()); + verify(clientThread, never()).runOnClientThreadOptional(any()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java new file mode 100644 index 00000000000..a982e875b2c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/events/MembershipPromptEventTest.java @@ -0,0 +1,154 @@ +package net.runelite.client.plugins.microbot.util.events; + +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.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class MembershipPromptEventTest +{ + private Client client; + private ClientThread clientThread; + private Object previousClient; + private Object previousClientThread; + + @Before + public void before() throws Exception + { + client = mock(Client.class); + clientThread = mock(ClientThread.class); + previousClient = swapStatic("client", client); + previousClientThread = swapStatic("clientThread", clientThread); + } + + @After + public void after() throws Exception + { + swapStatic("client", previousClient); + swapStatic("clientThread", previousClientThread); + } + + @Test + public void doesNotTouchClientThreadWhenNotLoggedIn() + { + for (GameState state : new GameState[]{GameState.LOGIN_SCREEN, GameState.LOGGING_IN, GameState.LOADING, GameState.HOPPING}) + { + when(client.getGameState()).thenReturn(state); + assertFalse(new MembershipPromptEvent().validate()); + } + verifyNoInteractions(clientThread); + } + + @Test + public void clientThreadTimeoutIsNotVisible() + { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + when(clientThread.runOnClientThreadOptional(any())).thenReturn(Optional.empty()); + + assertFalse(new MembershipPromptEvent().validate()); + } + + @Test + public void validatesWhenBondRedeemPromptIsOpen() throws Exception + { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + runClientThreadInline(); + visible(InterfaceID.F2PBondRedeem.UNIVERSE); + visible(InterfaceID.F2PBondRedeem.CLOSE); + + assertTrue(new MembershipPromptEvent().validate()); + } + + @Test + public void findsTheCloseButtonOfEachPrompt() + { + for (int[] prompt : MembershipPromptEvent.PROMPTS) + { + Client promptClient = mock(Client.class); + Widget root = widget(false); + Widget close = widget(false); + when(promptClient.getWidget(prompt[0])).thenReturn(root); + when(promptClient.getWidget(prompt[1])).thenReturn(close); + + assertSame(close, MembershipPromptEvent.findCloseButton(promptClient)); + } + } + + @Test + public void ignoresMissingOrHiddenPrompts() + { + assertNull(MembershipPromptEvent.findCloseButton(null)); + assertNull(MembershipPromptEvent.findCloseButton(client)); + + Widget hiddenRoot = widget(true); + Widget visibleRoot = widget(false); + Widget hiddenClose = widget(true); + Widget visibleClose = widget(false); + + when(client.getWidget(InterfaceID.F2PBondRedeem.UNIVERSE)).thenReturn(hiddenRoot); + when(client.getWidget(InterfaceID.F2PBondRedeem.CLOSE)).thenReturn(visibleClose); + assertNull(MembershipPromptEvent.findCloseButton(client)); + + when(client.getWidget(InterfaceID.F2PBondRedeem.UNIVERSE)).thenReturn(visibleRoot); + when(client.getWidget(InterfaceID.F2PBondRedeem.CLOSE)).thenReturn(hiddenClose); + assertNull(MembershipPromptEvent.findCloseButton(client)); + } + + @Test + public void executeWithoutPromptCompletesWithoutWaiting() + { + when(client.getGameState()).thenReturn(GameState.LOGIN_SCREEN); + + assertTrue(new MembershipPromptEvent().execute()); + verify(client, never()).getWidget(anyInt()); + } + + @SuppressWarnings("unchecked") + private void runClientThreadInline() throws Exception + { + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + } + + private void visible(int componentId) + { + Widget widget = widget(false); + when(client.getWidget(componentId)).thenReturn(widget); + } + + private static Widget widget(boolean hidden) + { + Widget widget = mock(Widget.class); + when(widget.isHidden()).thenReturn(hidden); + return widget; + } + + private static Object swapStatic(String name, Object value) throws Exception + { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + Object previous = field.get(null); + field.set(null, value); + return previous; + } +} 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..2c0bc338fa1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/TerminalLogoutPromptTest.java @@ -0,0 +1,251 @@ +/* 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 java.util.concurrent.atomic.AtomicLong; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; +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"); })); + } + + private static final Runnable NO_HOLD = () -> { throw new AssertionError("held a stop that requested suppression"); }; + + private static final class FakeSuppression { + private final AtomicLong clock; + private long deadline = Long.MIN_VALUE; + private int requests; + private int renewals; + private int holds; + + FakeSuppression(AtomicLong clock) { + this.clock = clock; + } + + void request() { + requests++; + if (!pending()) deadline = clock.get() + AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS; + } + + void renew() { + renewals++; + if (pending()) deadline = clock.get() + AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS; + } + + void hold() { + holds++; + } + + boolean pending() { + return clock.get() < deadline; + } + } + + private static boolean tick(TerminalLogoutPrompt prompt, FakeSuppression suppression, boolean stepRequests, + Rs2Player.TerminalLogoutState state, List messages) { + return prompt.throttledTick(request -> { + if (stepRequests) request.run(); + return state; + }, messages::add, suppression::request, suppression::renew, suppression::hold); + } + + @Test public void suppressionIsRequestedOnceAcrossRetriesAndAgainForANewStop() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + int[] requests = {0}; + int[] renewals = {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]++, () -> renewals[0]++, NO_HOLD)); + } + assertEquals(1, requests[0]); + assertEquals(1, renewals[0]); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++, () -> renewals[0]++, NO_HOLD)); + assertEquals(1, requests[0]); + prompt.begin("Second stop"); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> requests[0]++, () -> renewals[0]++, NO_HOLD)); + assertEquals(2, requests[0]); + assertEquals(2, renewals[0]); + assertEquals(List.of("First stop", "Second stop"), messages); + } + + @Test public void delayedLogoutKeepsRenewingSuppressionUntilTheLoginScreen() { + AtomicLong clock = new AtomicLong(); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + FakeSuppression suppression = new FakeSuppression(clock); + 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(tick(prompt, suppression, true, Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED, messages)); + assertTrue(suppression.pending()); + } + assertEquals(1, suppression.requests); + int renewals = suppression.renewals; + assertTrue(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.LOGGED_OUT, messages)); + assertTrue(suppression.pending()); + assertEquals(renewals, suppression.renewals); + assertEquals(0, suppression.holds); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void suppressionIsRenewedWhileATransientStateBlocksTheLogoutStep() { + AtomicLong clock = new AtomicLong(); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + FakeSuppression suppression = new FakeSuppression(clock); + List messages = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + assertFalse(tick(prompt, suppression, true, Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED, messages)); + long reconnectedAt = AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS * 3; + for (clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS); clock.get() < reconnectedAt; + clock.addAndGet(TerminalLogoutPrompt.RETRY_MILLIS)) { + assertFalse(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.INPUT_BLOCKED, messages)); + assertTrue("suppression lapsed at " + clock.get() + " ms", suppression.pending()); + } + assertEquals(1, suppression.requests); + assertTrue(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.LOGGED_OUT, messages)); + assertTrue(suppression.pending()); + assertEquals(0, suppression.holds); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void firstSuppressionExtendsARequestAnotherSourceAlreadyMade() { + AtomicLong clock = new AtomicLong(); + FakeSuppression suppression = new FakeSuppression(clock); + suppression.request(); + clock.set(AutoLoginSuppressionRequest.PENDING_LOGOUT_MILLIS - 4_000L); + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(clock::get); + 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(tick(prompt, suppression, true, Rs2Player.TerminalLogoutState.LOGOUT_REQUESTED, messages)); + assertTrue("suppression lapsed at " + clock.get() + " ms", suppression.pending()); + } + assertTrue(tick(prompt, suppression, false, Rs2Player.TerminalLogoutState.LOGGED_OUT, messages)); + assertEquals(List.of("Ran out of supplies"), messages); + } + + @Test public void stopFirstSeenAtTheLoginScreenHoldsAutoLoginBeforeNotifying() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List events = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + assertTrue(prompt.tick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, text -> events.add("notify"), + () -> events.add("request"), () -> events.add("renew"), () -> events.add("hold"))); + assertEquals(List.of("hold", "notify"), events); + assertFalse(prompt.tick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, text -> events.add("notify"), + () -> events.add("request"), () -> events.add("renew"), () -> events.add("hold"))); + assertEquals(List.of("hold", "notify"), events); + } + + @Test public void stopWithAPendingRequestDoesNotHoldAtTheLoginScreen() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List events = new ArrayList<>(); + prompt.begin("Ran out of supplies"); + assertFalse(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.INPUT_BLOCKED; + }, text -> events.add("notify"), () -> events.add("request"), () -> events.add("renew"), NO_HOLD)); + assertTrue(prompt.tick(request -> Rs2Player.TerminalLogoutState.LOGGED_OUT, text -> events.add("notify"), + () -> events.add("request"), () -> events.add("renew"), NO_HOLD)); + assertEquals(List.of("request", "renew", "notify"), events); + } + + @Test public void supplierTickNeverSuppresses() { + TerminalLogoutPrompt prompt = new TerminalLogoutPrompt(); + List messages = new ArrayList<>(); + prompt.begin("Stop"); + assertTrue(prompt.tick(() -> Rs2Player.TerminalLogoutState.LOGGED_OUT, messages::add)); + assertEquals(List.of("Stop"), 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(); + List suppressions = new ArrayList<>(); + 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, () -> suppressions.add("request"), () -> suppressions.add("renew"), + () -> suppressions.add("hold"))); + assertTrue(suppressions.isEmpty()); + assertTrue(messages.isEmpty()); + assertEquals("New stop", prompt.message()); + assertTrue(prompt.tick(request -> { + request.run(); + return Rs2Player.TerminalLogoutState.LOGGED_OUT; + }, messages::add, () -> suppressions.add("request"), () -> suppressions.add("renew"), + () -> suppressions.add("hold"))); + assertEquals(List.of("request", "renew"), suppressions); + 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 new file mode 100644 index 00000000000..075fcea47e3 --- /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 loginScreenConfirmsLogoutWithoutAnUncompletableSuppression() throws Exception { + check(GameState.LOGIN_SCREEN,false,Rs2Player.TerminalLogoutState.LOGGED_OUT,0); + } + + @Test public void clientThreadCannotDispatchOrRequestSuppression() throws Exception { + check(GameState.LOGGED_IN,true,Rs2Player.TerminalLogoutState.INPUT_BLOCKED,0); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java new file mode 100644 index 00000000000..c6246246ae7 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java @@ -0,0 +1,267 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import static org.junit.Assert.*; + +public class HopBudgetTest { + private static final long ONE_TOKEN_MILLIS = (long) Math.ceil(TimeUnit.HOURS.toMillis(1) / 93.0); + + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + private final AtomicLong clock = new AtomicLong(1_000_000L); + + private HopBudget budget(int capacity, int reserve) { + HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); + budget.configure(true, capacity, 93, reserve); + return budget; + } + + private static void hop(HopBudget budget, int times) { + for (int i = 0; i < times; i++) { + budget.recordHop(); + } + } + + @Test public void freshBudgetStartsFullAndAllowsHops() { + HopBudget budget = budget(390, 20); + assertEquals(390, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void hopsStopAtTheReserveAndResumeAfterOneTokenRefills() { + HopBudget budget = budget(30, 5); + hop(budget, 24); + assertEquals(6, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + hop(budget, 1); + assertEquals(5, budget.available()); + long wait = budget.millisUntilHopAllowed(); + assertTrue(Math.abs(wait - ONE_TOKEN_MILLIS) <= 1); + clock.addAndGet(wait - 2); + assertTrue(budget.millisUntilHopAllowed() > 0); + clock.addAndGet(2); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void refillNeverExceedsCapacity() { + HopBudget budget = budget(30, 5); + hop(budget, 10); + clock.addAndGet(TimeUnit.HOURS.toMillis(5)); + assertEquals(30, budget.available()); + } + + @Test public void lockoutEmptiesTheBudget() { + HopBudget budget = budget(390, 20); + budget.recordLockout(); + assertEquals(0, budget.available()); + assertTrue(budget.millisUntilHopAllowed() >= 20 * ONE_TOKEN_MILLIS); + } + + @Test public void disabledBudgetNeverWaitsButStillCounts() { + HopBudget budget = budget(10, 2); + budget.configure(false, 10, 93, 2); + hop(budget, 10); + assertEquals(0, budget.available()); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void reserveIsClampedBelowCapacity() { + HopBudget budget = budget(5, 50); + assertEquals(0L, budget.millisUntilHopAllowed()); + hop(budget, 1); + assertTrue(budget.millisUntilHopAllowed() > 0); + } + + @Test public void clockGoingBackwardsDoesNotDrainOrRefill() { + HopBudget budget = budget(30, 5); + hop(budget, 10); + clock.addAndGet(-TimeUnit.HOURS.toMillis(1)); + assertEquals(20, budget.available()); + } + + @Test public void fileStoreIsSharedBetweenClientsOnTheSameNetwork() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + HopBudget proxied = budget(30, 5); + proxied.persistTo(file, "proxy.10.0.0.1"); + + hop(first, 4); + hop(second, 3); + assertEquals(23, first.available()); + assertEquals(23, second.available()); + assertEquals(30, proxied.available()); + + HopBudget restarted = budget(30, 5); + restarted.persistTo(file, "direct"); + assertEquals(23, restarted.available()); + } + + @Test public void interruptedThreadStillUsesTheSharedFileAndKeepsItsInterrupt() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + + Thread.currentThread().interrupt(); + try { + first.recordHop(); + assertEquals(29, first.available()); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + assertEquals(29, second.available()); + } + + @Test public void corruptFileIsTreatedAsAFullBudget() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, "direct.tokens=abc\ndirect.updatedAt=\n".getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertEquals(30, budget.available()); + budget.recordHop(); + assertEquals(29, budget.available()); + } + + @Test public void nonFiniteOrNegativeTokensAreTreatedAsAFullBudget() throws Exception { + for (String tokens : new String[]{"NaN", "Infinity", "-5"}) { + Path file = folder.newFolder().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, ("direct.tokens=" + tokens + "\ndirect.updatedAt=" + clock.get() + "\n") + .getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertEquals(tokens, 30, budget.available()); + assertEquals(tokens, 0L, budget.millisUntilHopAllowed()); + } + } + + @Test public void writesLeaveNoTemporaryFilesBehind() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + hop(budget, 3); + try (java.util.stream.Stream files = Files.list(folder.getRoot().toPath())) { + assertEquals(java.util.Set.of(HopBudget.FILE_NAME, HopBudget.FILE_NAME + ".lock"), + files.map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet())); + } + assertTrue(new String(Files.readAllBytes(file), StandardCharsets.UTF_8).contains("direct.tokens=27")); + } + + @Test public void storedTimestampInTheFutureIsRepairedSoRefillResumes() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + hop(budget, 25); + clock.addAndGet(-TimeUnit.HOURS.toMillis(1)); + assertTrue(budget.millisUntilHopAllowed() > 0); + clock.addAndGet(ONE_TOKEN_MILLIS); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void persistToRepairsAFutureTimestampForTheCachedCheck() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + Files.write(file, ("direct.tokens=5.0\ndirect.updatedAt=" + (clock.get() + TimeUnit.HOURS.toMillis(1)) + "\n") + .getBytes(StandardCharsets.UTF_8)); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + assertTrue(budget.cachedMillisUntilHopAllowed() > 0); + clock.addAndGet(ONE_TOKEN_MILLIS); + assertEquals(0L, budget.cachedMillisUntilHopAllowed()); + } + + @Test public void zeroRefillRateIsClampedSoWaitsStayFinite() { + HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); + budget.configure(true, 30, 0, 5); + hop(budget, 25); + assertTrue(Math.abs(budget.millisUntilHopAllowed() - TimeUnit.HOURS.toMillis(1)) <= 1); + } + + @Test public void cachedWaitUsesThisClientsLastKnownState() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + hop(second, 25); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + assertTrue(first.millisUntilHopAllowed() > 0); + assertTrue(first.cachedMillisUntilHopAllowed() > 0); + first.configure(false, 30, 93, 5); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + } + + @Test public void unwritableFileFallsBackToThisClientOnly() throws Exception { + Path directory = folder.newFolder("blocked").toPath(); + HopBudget budget = budget(30, 5); + hop(budget, 2); + budget.persistTo(directory, "direct"); + assertEquals(28, budget.available()); + budget.recordHop(); + assertEquals(27, budget.available()); + } + + @Test public void networkKeyFollowsTheSocksProxy() { + String previousHost = System.getProperty("socksProxyHost"); + String previousPort = System.getProperty("socksProxyPort"); + try { + System.clearProperty("socksProxyHost"); + System.clearProperty("socksProxyPort"); + assertEquals("direct", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "proxy.example.com"); + System.setProperty("socksProxyPort", "10001"); + assertEquals("proxy.proxy.example.com_10001", HopBudget.networkKey()); + } finally { + restore("socksProxyHost", previousHost); + restore("socksProxyPort", previousPort); + } + } + + @Test public void networkKeySeparatesProxyPortsAndUsersWithoutStoringTheUsername() { + assertEquals("direct", HopBudget.networkKey(null, "1080", "user")); + assertEquals("direct", HopBudget.networkKey(" ", "1080", "user")); + assertEquals("proxy.fe80__1_1080", HopBudget.networkKey("fe80::1", "1080", null)); + assertNotEquals(HopBudget.networkKey("gate.example.com", "10001", null), + HopBudget.networkKey("gate.example.com", "10002", null)); + String first = HopBudget.networkKey("gate.example.com", "7000", "customer-session-a"); + String second = HopBudget.networkKey("gate.example.com", "7000", "customer-session-b"); + assertNotEquals(first, second); + assertFalse(first.contains("customer")); + assertTrue(first.matches("[A-Za-z0-9._-]+")); + } + + @Test public void networkKeyUsernamesWithEqualHashCodesGetSeparateKeys() { + assertEquals("Aa".hashCode(), "BB".hashCode()); + assertNotEquals(HopBudget.networkKey("gate.example.com", "7000", "Aa"), + HopBudget.networkKey("gate.example.com", "7000", "BB")); + } + + @Test public void freshClientReadsTheSharedFileBeforeAnsweringFromCache() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + hop(first, 25); + HopBudget fresh = budget(30, 5); + fresh.persistTo(file, "direct"); + assertTrue(fresh.cachedMillisUntilHopAllowed() > 0); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.java new file mode 100644 index 00000000000..3c1215ad2e4 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerBankedDirectWalkStateTest.java @@ -0,0 +1,108 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Client; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathConfig; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.HashMap; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WalkerBankedDirectWalkStateTest +{ + private static final WorldPoint PLAYER = new WorldPoint(3222, 3218, 0); + private static final WorldPoint TARGET = new WorldPoint(3400, 3400, 0); + private static final String DIRECT_WALK_FAILED = "rs2walker:walkWithBankedTransports:direct-walk-failed"; + + private MockedStatic microbot; + private MockedStatic player; + private MockedStatic tile; + private MockedStatic pathApi; + private MockedStatic planner; + private ShortestPathConfig originalConfig; + private Client client; + + @Before + public void setUp() + { + originalConfig = Rs2Walker.config; + Rs2Walker.config = mock(ShortestPathConfig.class); + Rs2Walker.routeStateForTesting().lastRouteClearReason = ""; + + client = mock(Client.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + + player = Mockito.mockStatic(Rs2Player.class); + tile = Mockito.mockStatic(Rs2Tile.class); + tile.when(() -> Rs2Tile.getReachableTilesFromTile(any(WorldPoint.class), anyInt())).thenReturn(new HashMap<>()); + + Rs2ActiveRouteStatus status = mock(Rs2ActiveRouteStatus.class); + pathApi = Mockito.mockStatic(Rs2PathApi.class); + pathApi.when(Rs2PathApi::getActiveRouteStatus).thenReturn(status); + + TransportRouteAnalysis comparison = mock(TransportRouteAnalysis.class); + when(comparison.getDirectDistance()).thenReturn(250); + when(comparison.getTransportEdgesToBank()).thenReturn(List.of()); + planner = Mockito.mockStatic(Rs2WalkerBankingPlanner.class); + planner.when(() -> Rs2WalkerBankingPlanner.compareRoutes(isNull(), eq(TARGET), isNull())).thenReturn(comparison); + planner.when(() -> Rs2WalkerBankingPlanner.getRequiredTransportEdgesFromBank(any())).thenReturn(List.of()); + planner.when(() -> Rs2WalkerBankingPlanner.getMissingTransportEdges(anyList())).thenReturn(List.of()); + planner.when(() -> Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout(anyList(), anyList())) + .thenReturn(Rs2TransportLoadout.empty()); + } + + @After + public void tearDown() + { + planner.close(); + pathApi.close(); + tile.close(); + player.close(); + microbot.close(); + Rs2Walker.config = originalConfig; + } + + @Test + public void transientMovingFromDirectWalkKeepsTheRoute() + { + player.when(Rs2Player::getWorldLocation).thenReturn(PLAYER, (WorldPoint) null); + + WalkerState state = Rs2Walker.walkWithBankedTransportsAndState(TARGET, 5, false); + + assertEquals(WalkerState.MOVING, state); + assertNotEquals(DIRECT_WALK_FAILED, Rs2Walker.getLastRouteClearReason()); + pathApi.verify(Rs2PathApi::cancelAndClearActiveRoute, Mockito.never()); + } + + @Test + public void terminalExitFromDirectWalkClearsTheRoute() + { + player.when(Rs2Player::getWorldLocation).thenReturn(PLAYER); + when(client.isClientThread()).thenReturn(false, true); + + WalkerState state = Rs2Walker.walkWithBankedTransportsAndState(TARGET, 5, false); + + assertEquals(WalkerState.EXIT, state); + assertEquals(DIRECT_WALK_FAILED, Rs2Walker.getLastRouteClearReason()); + pathApi.verify(Rs2PathApi::cancelAndClearActiveRoute); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java new file mode 100644 index 00000000000..a66bd13ef18 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/world/Rs2WorldUtilHopWorldTest.java @@ -0,0 +1,82 @@ +package net.runelite.client.plugins.microbot.util.world; + +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class Rs2WorldUtilHopWorldTest { + + @Test + public void failedHopIsAttemptedOncePerAttempt() { + Client client = mock(Client.class); + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + WorldHoppingConfig config = WorldHoppingConfig.builder() + .maxAttemptsPerWorld(2) + .baseHopDelay(10) + .maxHopDelay(10) + .build(); + + try (MockedStatic microbot = Mockito.mockStatic(Microbot.class, Mockito.CALLS_REAL_METHODS); + MockedStatic shop = Mockito.mockStatic(Rs2Shop.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + microbot.when(() -> Microbot.hopToWorld(anyInt())).thenReturn(false); + microbot.when(Microbot::isHopping).thenReturn(false); + player.when(Rs2Player::getWorld).thenReturn(301); + + assertFalse(Rs2WorldUtil.hopWorld(null, 302, 0, config)); + + microbot.verify(() -> Microbot.hopToWorld(302), times(2)); + } + } + + @Test + public void waitsForLoginBeforeHopping() { + Client client = mock(Client.class); + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + WorldHoppingConfig config = WorldHoppingConfig.builder() + .maxAttemptsPerWorld(1) + .baseHopDelay(10) + .maxHopDelay(10) + .build(); + AtomicInteger world = new AtomicInteger(301); + + try (MockedStatic microbot = Mockito.mockStatic(Microbot.class, Mockito.CALLS_REAL_METHODS); + MockedStatic shop = Mockito.mockStatic(Rs2Shop.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic player = Mockito.mockStatic(Rs2Player.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::isLoggedIn).thenReturn(false, false, true); + microbot.when(() -> Microbot.hopToWorld(anyInt())).thenAnswer(invocation -> { + if (!Microbot.isLoggedIn()) { + return false; + } + world.set(invocation.getArgument(0)); + return true; + }); + microbot.when(Microbot::isHopping).thenReturn(false); + player.when(Rs2Player::getWorld).thenAnswer(invocation -> world.get()); + + assertTrue(Rs2WorldUtil.hopWorld(null, 302, 0, config)); + + microbot.verify(() -> Microbot.hopToWorld(302), times(1)); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java b/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java new file mode 100644 index 00000000000..4347804b12b --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/ClientUITest.java @@ -0,0 +1,295 @@ +package net.runelite.client.ui; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.ConsoleTarget; +import com.google.common.collect.Iterables; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.concurrent.atomic.AtomicReference; +import javax.inject.Provider; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTabbedPane; +import javax.swing.JTextArea; +import javax.swing.SwingUtilities; +import javax.swing.text.Document; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.config.RuneLiteConfig; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.events.ClientShutdown; +import net.runelite.client.input.MouseManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.LoggerFactory; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class ClientUITest +{ + private static final String CONFIG_GROUP = "runelite"; + private static final String SIDEBAR_TAB = "clientSidebarTab"; + private static final String SIDEBAR_CLOSED = "clientSidebarClosed"; + + private ConfigManager configManager; + private EventBus eventBus; + private ClientUI clientUI; + private PrintStream originalOut; + private ConsoleAppender stdoutAppender; + + @Before + public void setUp() throws Exception + { + originalOut = System.out; + configManager = mock(ConfigManager.class); + eventBus = new EventBus(); + Constructor constructor = ClientUI.class.getDeclaredConstructor(RuneLiteConfig.class, MouseManager.class, + Client.class, ConfigManager.class, Provider.class, EventBus.class, boolean.class, String.class); + constructor.setAccessible(true); + clientUI = constructor.newInstance(mock(RuneLiteConfig.class), null, null, configManager, + (Provider) () -> null, eventBus, false, "Microbot"); + eventBus.register(clientUI); + } + + @After + public void tearDown() throws Exception + { + System.setOut(originalOut); + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + ConsoleLogAppender consoleLogAppender = (ConsoleLogAppender) get("consoleLogAppender"); + if (consoleLogAppender != null) + { + consoleLogAppender.uninstall(); + } + if (stdoutAppender != null) + { + root.detachAppender(stdoutAppender); + stdoutAppender.stop(); + ConsoleLogAppender.bindStdoutAppenders(context, ConsoleTarget.SystemOut.getStream()); + } + } + + @Test + public void removingTheOpenTabStoresTheTabShownInstead() throws Exception + { + NavigationButton first = button("First", 1); + NavigationButton second = button("Second", 2); + onEdt(() -> + { + installSidebar(); + clientUI.addNavigation(first); + clientUI.addNavigation(second); + clientUI.openPanel(first, true); + clientUI.openPanel(second, true); + clientUI.removeNavigation(second); + }); + + verify(configManager).setConfiguration(CONFIG_GROUP, SIDEBAR_TAB, "First"); + } + + @Test + public void removingTheOpenTabWhileClosingKeepsTheSavedTab() throws Exception + { + NavigationButton first = button("First", 1); + NavigationButton second = button("Second", 2); + onEdt(() -> + { + installSidebar(); + clientUI.addNavigation(first); + clientUI.addNavigation(second); + clientUI.openPanel(first, true); + clientUI.openPanel(second, true); + }); + + eventBus.post(new ClientShutdown()); + onEdt(() -> clientUI.removeNavigation(second)); + + verify(configManager, never()).setConfiguration(eq(CONFIG_GROUP), eq(SIDEBAR_TAB), any(String.class)); + verify(configManager, never()).unsetConfiguration(CONFIG_GROUP, SIDEBAR_TAB); + } + + @Test + public void removingTheOpenTabWhileClosingDoesNotReopenAClosedSidebar() throws Exception + { + NavigationButton first = button("First", 1); + NavigationButton second = button("Second", 2); + AtomicReference sidebar = new AtomicReference<>(); + onEdt(() -> + { + installSidebar(); + sidebar.set((JTabbedPane) get("sidebar")); + clientUI.addNavigation(first); + clientUI.addNavigation(second); + clientUI.openPanel(first, true); + clientUI.openPanel(second, true); + sidebar.get().setVisible(false); + pushHistory(); + }); + + eventBus.post(new ClientShutdown()); + onEdt(() -> clientUI.removeNavigation(second)); + + verify(configManager, never()).setConfiguration(eq(CONFIG_GROUP), eq(SIDEBAR_CLOSED), any(Boolean.class)); + verify(configManager, never()).unsetConfiguration(CONFIG_GROUP, SIDEBAR_CLOSED); + assertFalse(sidebar.get().isVisible()); + } + + @Test + public void consoleInitializationKeepsStdoutLogLinesOutOfThePanel() throws Exception + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + System.setOut(new PrintStream(terminal, true, StandardCharsets.UTF_8)); + + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + stdoutAppender = new ConsoleAppender<>(); + stdoutAppender.setContext(context); + stdoutAppender.setEncoder(encoder); + stdoutAppender.start(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + root.addAppender(stdoutAppender); + + AtomicReference panel = new AtomicReference<>(); + onEdt(() -> panel.set(new LogConsolePanel())); + set("consolePanel", panel.get()); + Method initialize = ClientUI.class.getDeclaredMethod("initializeConsoleLogging"); + initialize.setAccessible(true); + initialize.invoke(clientUI); + + String line = "console init probe " + System.nanoTime(); + root.warn(line); + onEdt(() -> { }); + onEdt(() -> { }); + + JTextArea textArea = (JTextArea) ((JScrollPane) panel.get().getComponent(0)).getViewport().getView(); + AtomicReference text = new AtomicReference<>(); + onEdt(() -> + { + Document document = textArea.getDocument(); + text.set(document.getText(0, document.getLength())); + }); + + assertEquals(1, occurrences(text.get(), line)); + assertTrue(terminal.toString(StandardCharsets.UTF_8).contains(line)); + } + + private void installSidebar() throws Exception + { + JTabbedPane sidebar = new JTabbedPane(JTabbedPane.RIGHT); + sidebar.setSelectedIndex(-1); + Collection entries = (Collection) get("sidebarEntries"); + sidebar.addChangeListener(ev -> + { + int index = sidebar.getSelectedIndex(); + try + { + NavigationButton selected = index < 0 ? null : Iterables.get(entries, index); + if (selected == get("selectedTab")) + { + return; + } + set("selectedTab", selected); + if (sidebar.isVisible()) + { + pushHistory(); + } + } + catch (ReflectiveOperationException ex) + { + throw new IllegalStateException(ex); + } + }); + set("sidebar", sidebar); + set("content", new JPanel()); + } + + private void pushHistory() throws ReflectiveOperationException + { + Method pushHistory = ClientUI.class.getDeclaredMethod("pushHistory"); + pushHistory.setAccessible(true); + pushHistory.invoke(clientUI); + } + + private static NavigationButton button(String tooltip, int priority) + { + return NavigationButton.builder() + .tooltip(tooltip) + .priority(priority) + .icon(new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB)) + .panel(new PluginPanel() + { + }) + .build(); + } + + private Object get(String name) throws ReflectiveOperationException + { + Field field = ClientUI.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(clientUI); + } + + private void set(String name, Object value) throws ReflectiveOperationException + { + Field field = ClientUI.class.getDeclaredField(name); + field.setAccessible(true); + field.set(clientUI, value); + } + + private static int occurrences(String text, String needle) + { + int count = 0; + for (int i = text.indexOf(needle); i >= 0; i = text.indexOf(needle, i + 1)) + { + count++; + } + return count; + } + + private static void onEdt(ThrowingRunnable runnable) throws Exception + { + AtomicReference failure = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> + { + try + { + runnable.run(); + } + catch (Exception ex) + { + failure.set(ex); + } + }); + if (failure.get() != null) + { + throw failure.get(); + } + } + + private interface ThrowingRunnable + { + void run() throws Exception; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java new file mode 100644 index 00000000000..5339de5e8e4 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/ConsoleLogAppenderTest.java @@ -0,0 +1,253 @@ +package net.runelite.client.ui; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import ch.qos.logback.core.joran.spi.JoranException; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ConsoleLogAppenderTest +{ + private static final String CONFIG = "" + + "" + + "%msg%n" + + "" + + "" + + ""; + + private static final String NO_STDOUT_CONFIG = ""; + + private PrintStream originalOut; + private LoggerContext context; + + @Before + public void setUp() + { + originalOut = System.out; + context = new LoggerContext(); + } + + @After + public void tearDown() + { + System.setOut(originalOut); + context.stop(); + } + + @Test + public void logLineReachesTheConsoleOnceAndStdoutOnce() + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + ConsoleAppender stdout = new ConsoleAppender<>(); + stdout.setContext(context); + stdout.setEncoder(encoder); + stdout.start(); + root.addAppender(stdout); + + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + + ConsoleLogAppender.bindStdoutAppenders(context, terminal); + ConsoleLogAppender appender = new ConsoleLogAppender(console::append); + appender.setContext(context); + appender.start(); + root.addAppender(appender); + + root.info("flood line"); + System.out.println("plain print"); + + String panel = console + consolePanel.toString(StandardCharsets.UTF_8); + assertEquals(1, occurrences(panel, "flood line")); + assertEquals(1, occurrences(panel, "plain print")); + + String out = terminal.toString(StandardCharsets.UTF_8); + assertEquals(1, occurrences(out, "flood line")); + assertEquals(1, occurrences(out, "plain print")); + } + + @Test + public void stoppingTheLoggerContextLeavesStdoutOpen() + { + CloseTrackingStream terminal = new CloseTrackingStream(); + + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern("%msg%n"); + encoder.start(); + ConsoleAppender stdout = new ConsoleAppender<>(); + stdout.setContext(context); + stdout.setEncoder(encoder); + stdout.start(); + root.addAppender(stdout); + + ConsoleLogAppender.bindStdoutAppenders(context, terminal); + root.info("before stop"); + context.stop(); + + assertFalse(terminal.closed); + assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "before stop")); + } + + @Test + public void reconfigurationKeepsTheConsoleAppenderAndStdoutBinding() throws JoranException + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + configure(); + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + ConsoleLogAppender.install(context, terminal, console::append); + + context.reset(); + configure(); + context.getLogger(Logger.ROOT_LOGGER_NAME).info("after reset"); + + assertEquals(1, occurrences(console.toString(), "after reset")); + assertEquals(0, occurrences(consolePanel.toString(StandardCharsets.UTF_8), "after reset")); + assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "after reset")); + } + + @Test + public void lineLoggedBeforeReconfigurationEndsDoesNotLeaveStdoutUnbound() throws JoranException + { + ByteArrayOutputStream terminal = new ByteArrayOutputStream(); + ByteArrayOutputStream consolePanel = new ByteArrayOutputStream(); + StringBuilder console = new StringBuilder(); + + configure(); + System.setOut(new PrintStream(new Tee(consolePanel, terminal), true, StandardCharsets.UTF_8)); + ConsoleLogAppender.install(context, terminal, console::append); + + context.reset(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + root.info("during reset"); + configure(); + root.info("after reset"); + + assertEquals(1, occurrences(console.toString(), "during reset")); + assertEquals(1, occurrences(console.toString(), "after reset")); + assertEquals(0, occurrences(consolePanel.toString(StandardCharsets.UTF_8), "after reset")); + assertEquals(1, occurrences(terminal.toString(StandardCharsets.UTF_8), "after reset")); + } + + @Test + public void stoppingTheLoggerContextDetachesTheConsoleAppender() + { + ConsoleLogAppender appender = ConsoleLogAppender.install(context, new ByteArrayOutputStream(), line -> { }); + + context.stop(); + + assertFalse(appender.isStarted()); + assertFalse(context.getLogger(Logger.ROOT_LOGGER_NAME).isAttached(appender)); + } + + @Test + public void reconfigurationWithoutStdoutStopsSearchingAfterBoundedLines() throws JoranException + { + configure(); + ConsoleLogAppender appender = ConsoleLogAppender.install(context, new ByteArrayOutputStream(), line -> { }); + + context.reset(); + configure(NO_STDOUT_CONFIG); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + for (int i = 0; i < ConsoleLogAppender.MAX_REBIND_ATTEMPTS - 1; i++) + { + root.info("line {}", i); + } + assertTrue(appender.isRebindPending()); + + root.info("last line"); + + assertFalse(appender.isRebindPending()); + } + + @Test + public void uninstallRemovesTheResetListener() throws JoranException + { + StringBuilder console = new StringBuilder(); + configure(); + ConsoleLogAppender.install(context, new ByteArrayOutputStream(), console::append).uninstall(); + + context.reset(); + configure(); + context.getLogger(Logger.ROOT_LOGGER_NAME).info("after uninstall"); + + assertTrue(context.getCopyOfListenerList().isEmpty()); + assertEquals(0, occurrences(console.toString(), "after uninstall")); + } + + private void configure() throws JoranException + { + configure(CONFIG); + } + + private void configure(String config) throws JoranException + { + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8))); + } + + private static int occurrences(String text, String needle) + { + int count = 0; + for (int i = text.indexOf(needle); i >= 0; i = text.indexOf(needle, i + 1)) + { + count++; + } + return count; + } + + private static final class CloseTrackingStream extends ByteArrayOutputStream + { + private boolean closed; + + @Override + public void close() + { + closed = true; + } + } + + private static final class Tee extends OutputStream + { + private final OutputStream first; + private final OutputStream second; + + private Tee(OutputStream first, OutputStream second) + { + this.first = first; + this.second = second; + } + + @Override + public void write(int b) throws IOException + { + first.write(b); + second.write(b); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java b/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java new file mode 100644 index 00000000000..ed4ed2eb62a --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/ui/LogConsolePanelTest.java @@ -0,0 +1,307 @@ +package net.runelite.client.ui; + +import java.awt.Point; +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.event.MouseWheelListener; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import javax.swing.JScrollBar; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class LogConsolePanelTest +{ + private static final int DOCUMENT_CAP = 100_000; + + private LogConsolePanel panel; + private JScrollPane scrollPane; + private JTextArea textArea; + + @Before + public void setUp() throws Exception + { + onEdt(() -> + { + panel = new LogConsolePanel(); + scrollPane = (JScrollPane) panel.getComponent(0); + textArea = (JTextArea) scrollPane.getViewport().getView(); + panel.setSize(400, 160); + layOut(); + }); + panel.append(lines(0, 200)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void wheelUpWinsOverAnAlreadyQueuedSnap() throws Exception + { + onEdt(() -> + { + panel.append(lines(200, 50)); + SwingUtilities.invokeLater(() -> + { + layOut(); + MouseWheelEvent wheelUp = new MouseWheelEvent(scrollPane, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), + 0, 10, 10, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -1); + for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) + { + listener.mouseWheelMoved(wheelUp); + } + }); + }); + settle(); + assertFalse(isAtBottom()); + + panel.append(lines(250, 50)); + settle(); + assertFalse(isAtBottom()); + } + + @Test + public void pageUpStopsFollowing() throws Exception + { + press(KeyEvent.VK_PAGE_UP, 0); + settle(); + assertFalse(isAtBottom()); + + panel.append(lines(200, 50)); + settle(); + assertFalse(isAtBottom()); + } + + @Test + public void ctrlEndAfterPageUpResumesFollowing() throws Exception + { + press(KeyEvent.VK_PAGE_UP, 0); + settle(); + panel.append(lines(200, 50)); + settle(); + assertFalse(isAtBottom()); + + press(KeyEvent.VK_END, InputEvent.CTRL_DOWN_MASK); + settle(); + panel.append(lines(250, 50)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void dragSelectionSurvivesTheNextBatch() throws Exception + { + AtomicReference start = new AtomicReference<>(); + onEdt(() -> + { + Point view = scrollPane.getViewport().getViewPosition(); + start.set(new Point(5, view.y + 20)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_PRESSED, start.get().x, start.get().y)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_DRAGGED, start.get().x + 60, start.get().y + 20)); + textArea.dispatchEvent(mouse(MouseEvent.MOUSE_RELEASED, start.get().x + 60, start.get().y + 20)); + }); + settle(); + String selected = onEdtGet(textArea::getSelectedText); + assertTrue(selected != null && !selected.isEmpty()); + + panel.append(lines(200, 50)); + settle(); + assertEquals(selected, onEdtGet(textArea::getSelectedText)); + } + + @Test + public void wheelDownToTheBottomResumesFollowing() throws Exception + { + onEdt(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum() / 2); + }); + wheel(-3); + settle(); + assertFalse(isAtBottom()); + + onEdt(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + }); + wheel(3); + settle(); + panel.append(lines(200, 50)); + settle(); + assertTrue(isAtBottom()); + } + + @Test + public void starvedEdtInsertsOnlyWhatAFollowerKeeps() throws Exception + { + List inserts = new ArrayList<>(); + onEdt(() -> textArea.getDocument().addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent ev) + { + inserts.add(ev.getLength()); + } + + @Override + public void removeUpdate(DocumentEvent ev) + { + } + + @Override + public void changedUpdate(DocumentEvent ev) + { + } + })); + + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean released = new AtomicBoolean(); + SwingUtilities.invokeLater(() -> + { + blocked.countDown(); + try + { + released.set(release.await(30, TimeUnit.SECONDS)); + } + catch (InterruptedException ex) + { + Thread.currentThread().interrupt(); + } + }); + assertTrue(blocked.await(30, TimeUnit.SECONDS)); + + int next = 1_000; + String last = null; + while (next < 1_000 + 80_000) + { + panel.append(lines(next, 1_000)); + next += 1_000; + last = "line " + (next - 1) + "\n"; + } + release.countDown(); + settle(); + assertTrue(released.get()); + + int largest = inserts.stream().mapToInt(Integer::intValue).max().orElse(0); + assertTrue("largest insert " + largest, largest <= DOCUMENT_CAP); + String text = onEdtGet(() -> + { + try + { + return textArea.getDocument().getText(0, textArea.getDocument().getLength()); + } + catch (BadLocationException ex) + { + throw new IllegalStateException(ex); + } + }); + assertTrue(text.length() <= DOCUMENT_CAP); + assertTrue(text.endsWith(last)); + assertNotEquals(-1, text.indexOf("line " + (next - 2) + "\n")); + } + + private void press(int keyCode, int modifiers) throws Exception + { + onEdt(() -> + { + KeyEvent key = new KeyEvent(textArea, KeyEvent.KEY_PRESSED, System.currentTimeMillis(), + modifiers, keyCode, KeyEvent.CHAR_UNDEFINED); + for (KeyListener listener : textArea.getKeyListeners()) + { + listener.keyPressed(key); + } + Object binding = textArea.getInputMap().get(KeyStroke.getKeyStrokeForEvent(key)); + textArea.getActionMap().get(binding).actionPerformed(new ActionEvent(textArea, ActionEvent.ACTION_PERFORMED, null)); + }); + } + + private void wheel(int rotation) throws Exception + { + onEdt(() -> + { + MouseWheelEvent event = new MouseWheelEvent(scrollPane, MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), + 0, 10, 10, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, rotation); + for (MouseWheelListener listener : scrollPane.getMouseWheelListeners()) + { + listener.mouseWheelMoved(event); + } + }); + } + + private MouseEvent mouse(int id, int x, int y) + { + int modifiers = id == MouseEvent.MOUSE_RELEASED ? 0 : InputEvent.BUTTON1_DOWN_MASK; + return new MouseEvent(textArea, id, System.currentTimeMillis(), modifiers, x, y, 1, false, MouseEvent.BUTTON1); + } + + private void layOut() + { + for (int i = 0; i < 2; i++) + { + panel.doLayout(); + scrollPane.doLayout(); + scrollPane.getViewport().doLayout(); + } + } + + private void settle() throws Exception + { + for (int i = 0; i < 40; i++) + { + onEdt(this::layOut); + } + } + + private boolean isAtBottom() throws Exception + { + return onEdtGet(() -> + { + JScrollBar vertical = scrollPane.getVerticalScrollBar(); + return vertical.getValue() + vertical.getVisibleAmount() >= vertical.getMaximum() - 4; + }); + } + + private static String lines(int from, int count) + { + StringBuilder text = new StringBuilder(); + for (int i = from; i < from + count; i++) + { + text.append("line ").append(i).append('\n'); + } + return text.toString(); + } + + private static void onEdt(Runnable runnable) throws Exception + { + SwingUtilities.invokeAndWait(runnable); + } + + private static T onEdtGet(Supplier supplier) throws Exception + { + AtomicReference result = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> result.set(supplier.get())); + return result.get(); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java b/runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java new file mode 100644 index 00000000000..5954d2dfdd0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/util/ResetResistantAttachmentTest.java @@ -0,0 +1,178 @@ +package net.runelite.client.util; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.core.Appender; +import ch.qos.logback.core.joran.spi.JoranException; +import ch.qos.logback.core.read.ListAppender; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ResetResistantAttachmentTest +{ + private static final String CONFIG = "" + + "" + + "" + + ""; + + private LoggerContext context; + private ListAppender appender; + + @Before + public void setUp() throws JoranException + { + context = new LoggerContext(); + configure(); + appender = new ListAppender<>(); + appender.setContext(context); + appender.start(); + } + + @After + public void tearDown() + { + context.stop(); + } + + @Test + public void appenderIsReattachedOnceAfterReconfiguration() throws JoranException + { + ResetResistantAttachment.attach(context, appender, appender::start); + + context.reset(); + configure(); + root().info("after reset"); + + assertTrue(appender.isStarted()); + assertEquals(1, attachedCount()); + assertEquals(1, appender.list.size()); + } + + @Test + public void appenderIsReattachedAfterEveryReset() throws JoranException + { + ResetResistantAttachment.attach(context, appender, appender::start); + + for (int i = 0; i < 3; i++) + { + context.reset(); + configure(); + } + root().info("after resets"); + + assertEquals(1, attachedCount()); + assertEquals(1, appender.list.size()); + } + + @Test + public void removedAttachmentLeavesNoListenerAndIsNotReattached() throws JoranException + { + ResetResistantAttachment attachment = ResetResistantAttachment.attach(context, appender, appender::start); + + attachment.remove(); + context.reset(); + configure(); + root().info("after reset"); + + assertFalse(context.getCopyOfListenerList().contains(attachment)); + assertFalse(appender.isStarted()); + assertEquals(0, attachedCount()); + assertEquals(0, appender.list.size()); + } + + @Test + public void reattachingAfterRemoveKeepsOneListenerAndOneAttachment() throws JoranException + { + ResetResistantAttachment.attach(context, appender, appender::start).remove(); + appender.start(); + ResetResistantAttachment.attach(context, appender, appender::start); + + context.reset(); + configure(); + root().info("after restart and reset"); + + assertEquals(1, attachmentListeners()); + assertEquals(1, attachedCount()); + assertEquals(1, appender.list.size()); + } + + @Test + public void failingRestartStillReattachesAndLetsOtherListenersRun() throws JoranException + { + ResetResistantAttachment.attach(context, appender, () -> + { + throw new IllegalStateException("executor rejected"); + }); + ListAppender other = new ListAppender<>(); + other.setContext(context); + other.start(); + ResetResistantAttachment.attach(context, other, other::start); + + context.reset(); + configure(); + root().info("after reset"); + + assertEquals(1, attachedCount()); + assertEquals(1, other.list.size()); + } + + @Test + public void stoppingTheContextDetachesAndStopsTheAppender() + { + ResetResistantAttachment.attach(context, appender, appender::start); + + context.stop(); + + assertFalse(appender.isStarted()); + assertEquals(0, attachedCount()); + } + + private int attachedCount() + { + int count = 0; + Iterator> appenders = root().iteratorForAppenders(); + while (appenders.hasNext()) + { + if (appenders.next() == appender) + { + count++; + } + } + return count; + } + + private int attachmentListeners() + { + int count = 0; + for (LoggerContextListener listener : context.getCopyOfListenerList()) + { + if (listener instanceof ResetResistantAttachment) + { + count++; + } + } + return count; + } + + private Logger root() + { + return context.getLogger(Logger.ROOT_LOGGER_NAME); + } + + private void configure() throws JoranException + { + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(new ByteArrayInputStream(CONFIG.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index fac8ab0fc88..3054b91a799 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -117,6 +117,8 @@ net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, net.runelite.client.plugins.microbot.util.bank.Rs2Bank#withdrawLootItems(String, List): boolean -> net.runelite.api.ItemComposition#isTradeable(): boolean net.runelite.client.plugins.microbot.util.camera.Rs2Camera#getZoom(): int -> net.runelite.api.Client#getVarcIntValue(int): int net.runelite.client.plugins.microbot.util.camera.Rs2Camera#isTileOnScreen(LocalPoint): boolean -> net.runelite.api.Client#getPlane(): int +net.runelite.client.plugins.microbot.util.camera.Rs2Camera#readPitchDirectly(): int -> net.runelite.api.Client#getCameraPitch(): int +net.runelite.client.plugins.microbot.util.camera.Rs2Camera#readYawDirectly(): int -> net.runelite.api.Client#getCameraYaw(): int net.runelite.client.plugins.microbot.util.combat.Rs2Combat#getWeaponAttackStyle(): String -> net.runelite.api.EnumComposition#getIntValue(int): int net.runelite.client.plugins.microbot.util.combat.Rs2Combat#inCombat(): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.combat.Rs2Combat#setAttackStyle(WidgetInfo): boolean -> net.runelite.api.Client#getWidget(WidgetInfo): Widget @@ -544,7 +546,6 @@ net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel#getTransformedComposit net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.player.Rs2Player#checkIdleLogout(long): boolean -> net.runelite.api.Client#getKeyboardIdleTicks(): int net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.WorldView#getScene(): Scene @@ -990,4 +991,4 @@ net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#getRandomAccessible net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#getRandomAccessibleWorldFromRegion(WorldRegion, boolean, boolean, boolean): int -> net.runelite.api.Client#getWorldType(): EnumSet net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#getWorldStatistics(): Map -> net.runelite.api.Client#getWorldType(): EnumSet net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#isMemberAccount(): boolean -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#lambda$hopWorld$8(): boolean -> net.runelite.api.Client#getGameState(): GameState +net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil#lambda$hopWorld$7(): boolean -> net.runelite.api.Client#getGameState(): GameState