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..204451aee4c 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; the client thread and Swing thread get `false` instead. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. 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/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/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index d8b98fdfee2..2ff220dcae8 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 @@ -55,6 +55,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; @@ -376,6 +377,9 @@ public static boolean hopToWorld(int worldNumber) { log.error("Can't hop world, already trying to hop"); return false; } + if (Microbot.getClient().getWorld() != worldNumber && !awaitHopBudget(worldNumber)) { + return false; + } final net.runelite.api.World[] target = new net.runelite.api.World[1]; boolean prepared = Microbot.getClientThread().runOnClientThreadOptional(() -> { if (Rs2Combat.inCombat()) { @@ -464,6 +468,35 @@ public static boolean hopToWorld(int worldNumber) { return success; } + static boolean awaitHopBudget(int worldNumber) { + HopBudget budget = HopBudget.getInstance(); + long waitMs = budget.millisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } + if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { + log.warn("Hop limit reached - not hopping to world {}, next hop allowed in {}s", + worldNumber, TimeUnit.MILLISECONDS.toSeconds(waitMs)); + return false; + } + log.info("Hop limit reached - waiting {}s before hopping to world {}", + TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); + String previousStatus = status; + try { + while (waitMs > 0) { + 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; + } + } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { if (!isWorldSwitcherLoaded() && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { 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..e14b5f104e6 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; @@ -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; @@ -177,6 +184,8 @@ protected void startUp() throws AWTException InputArbiter.setDisabled(microbotConfig.disableInputYielding()); InputArbiter.setMotionThresholdPx(microbotConfig.inputMotionThresholdPx()); InputArbiter.setIdleResumeMs(microbotConfig.inputIdleResumeMs()); + HopBudget.getInstance().persistTo(new File(RuneLite.RUNELITE_DIR, HopBudget.FILE_NAME).toPath(), HopBudget.networkKey()); + applyHopBudgetConfig(); CanvasInputListener.attach(); Microbot.enableAutoRunOn = microbotConfig.enableAutoRunOn(); Microbot.useStaminaPotsIfNeeded = microbotConfig.useStaminaPotsIfNeeded(); @@ -323,7 +332,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 +371,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 @@ -518,6 +550,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/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/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/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java new file mode 100644 index 00000000000..10d2776a5ef --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -0,0 +1,232 @@ +package net.runelite.client.plugins.microbot.util.security; + +import lombok.extern.slf4j.Slf4j; + +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.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +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() { + String proxyHost = System.getProperty("socksProxyHost"); + if (proxyHost == null || proxyHost.isBlank()) { + return "direct"; + } + return "proxy." + proxyHost.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + } + + public void persistTo(Path file, String networkKey) { + store = new FileStore(file, networkKey, store); + } + + public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { + this.capacity = Math.max(1, capacity); + this.refillPerHour = Math.max(0, 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() { + if (!enabled) { + return 0L; + } + double missing = reserve + 1 - current().tokens; + if (missing <= 0) { + return 0L; + } + double perMilli = refillPerMilli(); + if (perMilli <= 0) { + return Long.MAX_VALUE; + } + return Math.max(1L, (long) Math.ceil(missing / perMilli)); + } + + 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() { + return refill(store.update(UnaryOperator.identity()), clock.getAsLong()); + } + + 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); + } + + 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; + } + } + + static final class FileStore implements Store { + private final Path file; + 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.tokensKey = networkKey + ".tokens"; + this.updatedAtKey = networkKey + ".updatedAt"; + this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); + } + + @Override + public synchronized State update(UnaryOperator change) { + boolean interrupted = Thread.interrupted(); + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.CREATE, + StandardOpenOption.READ, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + Properties properties = read(channel); + 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(channel, 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(); + } + } + } + + private State parse(Properties properties) { + String tokens = properties.getProperty(tokensKey); + String updatedAt = properties.getProperty(updatedAtKey); + if (tokens == null || updatedAt == null) { + return null; + } + try { + return new State(Double.parseDouble(tokens), Long.parseLong(updatedAt)); + } catch (NumberFormatException e) { + return null; + } + } + + private static Properties read(FileChannel channel) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) channel.size()); + while (buffer.hasRemaining() && channel.read(buffer, buffer.position()) > 0) { + } + Properties properties = new Properties(); + properties.load(new StringReader(new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8))); + return properties; + } + + private static void write(FileChannel channel, Properties properties) throws IOException { + StringWriter writer = new StringWriter(); + properties.store(writer, null); + ByteBuffer buffer = ByteBuffer.wrap(writer.toString().getBytes(StandardCharsets.UTF_8)); + channel.truncate(0); + long position = 0; + while (buffer.hasRemaining()) { + position += channel.write(buffer, position); + } + channel.force(false); + } + } +} 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/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/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/security/HopBudgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java new file mode 100644 index 00000000000..88f4fe095c0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/HopBudgetTest.java @@ -0,0 +1,164 @@ +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 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 previous = System.getProperty("socksProxyHost"); + try { + System.clearProperty("socksProxyHost"); + assertEquals("direct", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "proxy.example.com"); + assertEquals("proxy.proxy.example.com", HopBudget.networkKey()); + System.setProperty("socksProxyHost", "fe80::1"); + assertEquals("proxy.fe80__1", HopBudget.networkKey()); + } finally { + if (previous == null) { + System.clearProperty("socksProxyHost"); + } else { + System.setProperty("socksProxyHost", previous); + } + } + } +}