diff --git a/AGENTS.md b/AGENTS.md index 99bb0b26f40..127d7313e4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ RuneLite fork with a hidden always-on plugin hosting automation scripts. Composi - Offline client-thread lookup: `./microbot-cli ct `. - Test mode: `-Dmicrobot.test.mode=true -Dmicrobot.test.script=` → results in `~/.runelite/test-results/`. Protocol: `docs/AGENTIC_TESTING_LOOP.md`. +## Releases +- Before merging `development` -> `main` (which runs `release.yml`), run `scripts/run-release-e2e.sh full` (or `quick` at minimum) against the release candidate and paste the generated `summary.md` into the release PR. See `docs/RELEASE_E2E.md`. + ## In-game settings Use the settings search bar — tab indices shift on updates. Verify changes via `./microbot-cli varbit `. diff --git a/docs/AGENTIC_TESTING_LOOP.md b/docs/AGENTIC_TESTING_LOOP.md index c1f428e197d..f2c7fc7a85d 100644 --- a/docs/AGENTIC_TESTING_LOOP.md +++ b/docs/AGENTIC_TESTING_LOOP.md @@ -70,6 +70,7 @@ java -jar microbot.jar \ | `microbot.test.script` | Plugin descriptor name to auto-enable | required | | `microbot.test.timeout` | Max runtime in ms before forced exit | `120000` | | `microbot.test.output` | Directory for results/screenshots | `~/.runelite/test-results` | +| `microbot.test.world` | Log in to this world with the active profile instead of enabling AutoLogin | unset | ### Components @@ -419,7 +420,7 @@ Claude is multimodal. A screenshot of the game client showing a stuck character, ### ExampleScript as the reference test -The existing `ExampleScript` already validates all core APIs (caches, inventory, equipment, widgets, walker, dialogue, teleport, looting). It's the natural first target for the agentic loop. Future scripts can follow the same `check()` pattern and write `TestResult` output. +`ExampleScript` is no longer bundled with the client; it now comes from the Microbot-Hub example plugin (same package), which is an empty loop template. It is still a convenient first target for exercising the harness. Scripts that need real coverage should follow a `check()` pattern and write `TestResult` output. --- diff --git a/docs/AGENT_SERVER.md b/docs/AGENT_SERVER.md index ca216f344e6..65598af260b 100644 --- a/docs/AGENT_SERVER.md +++ b/docs/AGENT_SERVER.md @@ -667,6 +667,8 @@ curl -X POST -H "Content-Type: application/json" \ ### Script Lifecycle +The examples below use the Microbot-Hub `ExamplePlugin` (`net.runelite.client.plugins.microbot.example`); it is no longer bundled with the client. + #### GET /scripts Lists all microbot plugins with their active/enabled status. diff --git a/docs/INDEX.md b/docs/INDEX.md index 89f7541861e..5ad15d61c96 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,5 +7,6 @@ - [Runtime CLI](MICROBOT_CLI.md) - [Agent Server](AGENT_SERVER.md) - [Agentic Testing Loop](AGENTIC_TESTING_LOOP.md) +- [Release E2E Smoke Test](RELEASE_E2E.md) - [Entity Guides](entity-guides/README.md) - [Gallery](gallery.md) diff --git a/docs/RELEASE_E2E.md b/docs/RELEASE_E2E.md new file mode 100644 index 00000000000..4d2df60b601 --- /dev/null +++ b/docs/RELEASE_E2E.md @@ -0,0 +1,91 @@ +# Release E2E Smoke Test + +A live smoke test that runs the release candidate client against a real logged-in account before `development` is merged into `main`. That merge triggers `release.yml`, which publishes the jar. The test needs a display and a logged-in game account, so it cannot run in GitHub CI. It is a local gate. + +The test reuses the existing test-mode plumbing: `TestRunnerPlugin` logs the account in, starts the hidden `Release Smoke Harness` plugin, and the harness writes its results under `~/.runelite/test-results/`. That is the same flow the [F2P web walker harness](F2P_WEBWALKER_HARNESS.md) uses. + +## Account requirements + +- An F2P account that has finished Tutorial Island, saved as the active Microbot profile. +- A few coins and a few junk items in the inventory. The bank check deposits one item and withdraws it again. It looks for, in order: Burnt meat, Burnt fish, Burnt bread, Cowhide, Egg, Bones, Ashes, Bowl, Pot, Jug, Feather. It prefers an item that already has a stack in the bank, because a full F2P bank (400 stacks) refuses new item types. +- No bank PIN, or the PIN saved in the profile. +- Any F2P location is fine to start from. The harness walks to each start point itself, using Lumbridge castle and bank, the Lumbridge castle doors, Varrock west bank and the Grand Exchange. +- Log in to an F2P world. Set `MICROBOT_E2E_WORLD` to one, for example `383`. Otherwise AutoLogin uses its own world settings, and it defaults to members worlds. + +All actions are reversible and safe. The harness does no trading, drops nothing, places no Grand Exchange offers, does no combat and stays out of the Wilderness. Each check registers cleanups that put back what it changed: doors, bank contents, the open tab, the world, and Hub plugins it installed. If a cleanup fails, the check is marked failed. + +## Run + +```bash +DISPLAY=:0 MICROBOT_E2E_WORLD=383 scripts/run-release-e2e.sh quick +DISPLAY=:0 MICROBOT_E2E_WORLD=383 scripts/run-release-e2e.sh full +DISPLAY=:0 MICROBOT_E2E_WORLD=383 scripts/run-release-e2e.sh quick bank npc-dialogue +DISPLAY=:0 scripts/run-release-e2e.sh full --jar /path/to/microbot-2.6.30.jar +scripts/run-release-e2e.sh --list +``` + +By default the script builds `:client:shadowJar` and runs that jar, so the test exercises the shaded artifact rather than the Gradle classpath. Use `--jar` or `MICROBOT_E2E_JAR` to test the exact release candidate jar. It exits non-zero when any check fails or does not run. + +| Variable | Meaning | +|---|---| +| `MICROBOT_E2E_HOME` | `user.home` for the client, for an isolated runtime home that holds the saved profile | +| `MICROBOT_E2E_WORLD` | World to log in to. `TestRunnerPlugin` logs in with the active profile instead of enabling AutoLogin | +| `MICROBOT_E2E_EXPECTED_VERSION` | Version the client must report. Defaults to `microbot.version` from `gradle.properties`; set it empty to skip the comparison | +| `MICROBOT_E2E_TOTAL_TIMEOUT_MS` | Total budget for all checks. Defaults to 15 min (quick) and 35 min (full) | +| `MICROBOT_E2E_HOP_WORLD` | Target world for `world-hop`. Defaults to the least crowded plain F2P world in the same region | +| `MICROBOT_E2E_HUB_PLUGIN` | Hub plugin for `hub-plugins`. Defaults to `AttackRangesPlusPlugin`, which only draws an overlay | +| `MICROBOT_E2E_GRADLE` | Build command, for example `flock systemd-run --user --scope -p MemoryMax=6G ./gradlew` | +| `MICROBOT_E2E_OUTPUT_DIR` | Result directory. Defaults to `/.runelite/test-results/release-e2e` | +| `--fail-fast` | Stop at the first failure. `logout` still runs | + +The client runs with telemetry disabled, so test runs do not end up in the production error reports. + +## Checks + +| ID | Tier | Covers | +|---|---|---| +| `boot` | quick | `microbot.version` matches the expected version, every built-in always-on plugin is active, and `client.log` has no unexpected `ERROR` lines since startup | +| `login` | quick | `LOGGED_IN`, player location known, world known, login finished within `loginTimeoutMs` of JVM start | +| `state-caches` | quick | At the Lumbridge castle hall: all skills plausible; inventory cache equals the client container; cached varp 173 and a varbit equal the client values; NPC, object, ground item and player queryables return results | +| `widgets` | quick | Clicks the Skills and Inventory tab stones (widget click, verified with `Rs2Tab`) and finds the "Total level" text with `Rs2Widget.findWidget` | +| `walker-transport` | quick | Short walk in the castle courtyard; walk through a door the harness closes first (castle entrance tower), which must end with `ARRIVED` on the far side; walk up the castle stairs to the bank on plane 2 | +| `bank` | quick | Nearest bank, `openBank`, `depositOne` and `withdrawOne` on one junk item, `closeBank`, inventory identical to before | +| `npc-dialogue` | quick | Talks to a Lumbridge banker, reads the text, picks "What is this place?", continues, then walks away to close the dialogue. The bank must not open | +| `object-interaction` | quick | Opens and closes (or closes and opens) a Lumbridge castle door and confirms it is back in its original state | +| `agent-server` | quick | `/state`, `/inventory`, `/widgets/describe` and `/skills` with the token, plus the opaque 404 for a request without it. Starts the plugin for the check if it is off | +| `walker-medium` | full | Lumbridge courtyard to Varrock west bank. The walker may teleport, and `maxStepJump` records any jump | +| `grand-exchange` | full | Opens the Grand Exchange through the clerk and closes it. No offers | +| `world-hop` | full | `Microbot.hopToWorld` to another plain F2P world and back | +| `hub-plugins` | full | Plugin Hub manifest loaded with valid entries; install, start, stop and uninstall the Hub plugin (start and stop only if it is already installed) | +| `script-lifecycle` | full | Starts and stops `StateMachineExamplePlugin` twice through `Microbot.startPlugin`/`stopPlugin`: the loop runs, no thread is still running script code after stop, the pool stays at the `Script` core size, and the script is in `StateMachineScript.getRegistry()` while running | +| `logout` | quick, always runs | Stops AutoLogin, logs out with `Rs2Player.logout`, then scans the whole run's `client.log` for unexpected `ERROR` lines | + +The quick tier runs checks 1-9 and `logout`. The full tier adds the rest. Check ids passed on the command line run regardless of tier, in catalog order. + +## Reading results + +The script prints `summary.md`, a Markdown table you can paste into the release PR, and leaves `result.json` next to it: + +- `verdict`, `exitCode`, plus `passed`, `failed`, `skipped` and `notRun` counts. +- `results[]`: `id`, `description`, `tier`, `status` (`PASSED`, `FAILED`, `SKIPPED` or `NOT_RUN`), `durationMs`, `detail` (why it failed or was skipped), `details` (what the check observed, such as walk states, door ids, counts and dialogue transcripts) and `cleanupErrors`. +- `checks[]`, `errors` and `logTail` keep the generic `TestResult` format used by the other harnesses. + +`SKIPPED` means the account cannot exercise the check, for example the bank is full and no junk item has a bank stack, the profile has no bank PIN, or the Grand Exchange clerk refuses this account. It is not a failure, but say so in the PR. `NOT_RUN` comes from `--fail-fast` or from running out of the total budget, and it fails the run. + +When a check fails, look at `detail`, then at `details` in `result.json`, then at `/.runelite/logs/client.log`. Lines from the harness are tagged `[ReleaseE2E]` and run on `ReleaseE2E-` threads. To rerun one check, pass its id: `scripts/run-release-e2e.sh quick bank`. + +## Log allowlist + +`boot` and `logout` fail on any `ERROR` line in the current run's `client.log`, from the last `starting up, args:` banner onward. Known harmless lines are listed in `ClientLogScanner.DEFAULT_ALLOWLIST`, and each entry has a reason. Keep the list short. Add an entry only for a line that is expected and harmless during a healthy run, never to hide a real failure, and give it a unit test in `ClientLogScannerTest`. + +## Adding a check + +1. Add a method to `ReleaseSmokeChecks` taking a `CheckContext`. Walk to a fixed start point first with `walkToStart`. Use `context.require(condition, message)` for assertions, `context.skip(reason)` when the account cannot run it, and `context.detail(key, value)` to record what you observed. +2. Register a cleanup with `context.onCleanup(name, action)` before changing anything in game. Cleanups run in reverse order after the body, including after a timeout, and a cleanup that throws fails the check. +3. Add it to `all()` with `ReleaseCheck.quick(...)` or `ReleaseCheck.full(...)`, an id, a one-line description and a timeout long enough for its setup walk. Keep `logout` last. +4. Add the id to `CHECK_IDS` in `scripts/run-release-e2e.sh` and to the table above. +5. Follow the client-thread rules: game reads go through `Rs2*` helpers or `runOnClientThreadOptional`, queryables use the `*OnClientThread` terminals, and waits use `sleepUntil` with a timeout. + +## Release checklist + +Before merging the release PR (`development` -> `main`), build the release candidate, run `scripts/run-release-e2e.sh full` (or `quick` at minimum) against it, and paste `summary.md` into the release PR. A failing check blocks the release until it is fixed or written up in the PR as a known issue. diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index 04b4844a0e4..cd9b345700b 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -11,7 +11,7 @@ Each guide lists known pitfalls when working with one specific game entity type. | Items (inventory, bank, ground, equipment, shops) | [items.md](items.md) | Any code calling `Rs2Inventory`, `Rs2Bank`, `Rs2Equipment`, `Rs2GroundItem`, `Rs2Shop`, or `Rs2DepositBox` interaction helpers, or any helper that takes a list of item names and applies a single action to all of them | | Movement (walker, minimap, pathing) | [movement.md](movement.md) | Any code calling or modifying `Rs2Walker`, `Rs2MiniMap`, shortest-path marker handling, or minimap/canvas walk-click logic | | Death (graves, Death's Office, recovery) | [death.md](death.md) | Any code calling or modifying `Rs2Death`, `DeathRecoveryEvent`, `DeathEvent`, or handling graves, retrieval fees, and post-death item recovery | -| Worlds (selection, ping, hopping) | [worlds.md](worlds.md) | Any code measuring world latency, testing world reachability, or selecting worlds based on network availability | +| Worlds (selection, ping, hopping) | [worlds.md](worlds.md) | Any code measuring world latency, testing world reachability, selecting worlds based on network availability, or reading the local player while logging in or hopping | Ground-item pickup: [propagate dispatch failures](items.md#11-propagate-ground-item-dispatch-failures) and [preserve explicit Take](items.md#12-preserve-an-explicit-ground-item-take-when-a-widget-is-selected). @@ -50,3 +50,5 @@ Each entity guide is a numbered list of gotchas. Each entry follows this structu ## Adding a new gotcha to an existing guide Append a new numbered entry. Do **not** delete or rewrite existing entries unless they are factually wrong — each entry corresponds to a real failure mode and removing it loses the institutional memory of why the rule exists. + +Required-supply stops must suppress AutoLogin without blocking the next run: [items gotcha 15](items.md#15-suppress-autologin-only-for-confirmed-terminal-supply-stops). diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 6d5f9468c30..b882157b179 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -261,3 +261,14 @@ Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient(). ``` **Where this applies:** `Rs2GrandExchange`, `GrandExchangeWidget`, `Rs2Dialogue`, `Rs2Bank` X-amount prompts, and any helper reading chatbox prompts or GE offer state. + + +## 15. Suppress AutoLogin only for confirmed terminal supply stops + +An ordinary logout can be a break or world hop, so it must keep normal AutoLogin behavior. After confirming a required supply is unavailable in a live bank mirror, call `Rs2Player.logoutWithoutAutoLogin()` off the client thread and cancel the owning run's scheduler. Existing Inventory Setup callers retain their global-pause behavior. Scripts that prefer terminal logout must opt in per loader with `withMissingSupplyPolicy(Rs2InventorySetup.MissingSupplyPolicy.LOGOUT_AND_STOP)`; do not globally change every plugin's supply policy. Partial-stack acceptance and optional supply fallbacks remain separate. + +**Why this matters:** AutoLogin otherwise logs back in after supply exhaustion and the stopped bank loop retries indefinitely. Suppression clears on a new gameplay script run, a manual login after the logout, or an AutoLogin restart. Background AutoLogin and BreakHandler starts do not clear it. + +**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. diff --git a/docs/entity-guides/worlds.md b/docs/entity-guides/worlds.md index 8b5f436f730..b4a4ec3b824 100644 --- a/docs/entity-guides/worlds.md +++ b/docs/entity-guides/worlds.md @@ -19,3 +19,32 @@ int ping = Ping.tcpPing(InetAddress.getByName(world.getAddress())); **Where this applies:** `Ping`, `Rs2WorldUtil`, world selectors, login-world scoring, and any helper that tests world reachability. **Defensive check:** Test fallback with local sockets so the first port refuses the connection and the second succeeds. Live endpoint probes are useful evidence, but must not become a permanent world-to-port mapping. + +## 2. Treat the local player as absent during login, hop, and loading + +`Client.getLocalPlayer()` returns `null` on the login screen, while hopping, and during some loading states. Scripts keep ticking through those states, so any helper that reads the local player must check for `null` and return the value its callers already treat as "no player". + +**Why this matters:** `Rs2Player.getWorldLocation_Internal()` and `Rs2Player.getPoseAnimation()` dereferenced the local player directly. Telemetry for 2.6.30 recorded about 100 NullPointerExceptions from these lambdas in one day, many reached through `Rs2PlayerStateCache.refreshLocalPlayer()` from ordinary `Rs2Player.getWorldLocation()` calls. + +**Pattern to follow:** + +```java +// Wrong: throws on the client thread whenever the player is not loaded. +return Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getLocalPlayer().getPoseAnimation() +).orElse(-1); + +// Right: read once, yield an empty Optional, and let orElse supply the "no player" value. +return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getPoseAnimation(); +}).orElse(-1); +``` + +**Where this applies:** `Rs2Player` accessors (`getWorldLocation_Internal`, `getPoseAnimation`, `getAnimation`, `getCombatLevel`, `getLocalPlayer`, `getLocalLocation`, `getGraphicId`, `hasSpotAnimation`), the position accessors of `Rs2ActorModel` (`getWorldView`, `getWorldLocation`, `getLocalLocation`, `projectActorLocationToMainWorld`) and its `getAnimation` (returns -1), which `new Rs2PlayerModel()` reaches with a null actor, and any new helper that reads the local player. + +**Defensive check:** Mock `Client.getLocalPlayer()` to return `null` and assert each accessor returns its fallback, as in `Rs2PlayerNullLocalPlayerTest` and `Rs2PlayerModelNullLocalPlayerTest`. + +**Wrapped locations:** `Rs2Player.getRs2WorldPoint()` still returns an `Rs2WorldPoint`, but it can wrap a `null` `WorldPoint` in these states. `pathTo` returns `null` and `distanceToPath` returns `Integer.MAX_VALUE` for it; check `getWorldPoint() != null` before calling `getX()`, `getY()` or `getPlane()`, as in `Rs2WorldPointNullPointTest`. + +**Location consumers:** `Rs2Player.getWorldLocation()` itself returns `null` in these states. Guard it before passing it to `Rs2RouteRequest.to`/`toAny` (which reject a null start) or `WorldPoint.distanceTo`. `Rs2Bank.getNearestBank`, `getNearestBankRoute` and `getPathToNearestBank` return no bank, `isNearBank` returns `false`, and `Rs2Walker.getDistanceBetween` returns `Integer.MAX_VALUE` for a null endpoint, as in `Rs2BankNullPlayerLocationTest`. diff --git a/gradle.properties b/gradle.properties index 0e2fa03eafb..3a10760e10a 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.30 +microbot.version=2.6.31 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/callback/ClientThread.java b/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java index 12ecf848cd8..69fd69e722c 100644 --- a/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java +++ b/runelite-client/src/main/java/net/runelite/client/callback/ClientThread.java @@ -34,6 +34,7 @@ import java.util.Iterator; import java.util.Optional; import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; import java.util.function.Supplier; @@ -47,6 +48,10 @@ public class ClientThread protected ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); public Future scheduledFuture; + private static final long TIMEOUT_WARNING_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(30); + private final AtomicLong lastTimeoutWarningNanos = new AtomicLong(); + long clientThreadTimeoutMillis = 10000; + @Inject private Client client; @@ -85,13 +90,17 @@ public Optional runOnClientThreadOptional(Callable method) { final FutureTask task = new FutureTask<>(method); invoke(task); try { - return Optional.ofNullable(task.get(10000, TimeUnit.MILLISECONDS)); + return Optional.ofNullable(task.get(clientThreadTimeoutMillis, TimeUnit.MILLISECONDS)); } catch (InterruptedException | TimeoutException | ExecutionException e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); return Optional.empty(); } task.cancel(true); + if (e instanceof TimeoutException) { + logClientThreadTimeout(); + return Optional.empty(); + } if (!Microbot.isDebug()) { log.error("Exception during task execution: {}: {}\n{}", e.getClass().getSimpleName(), e.getMessage(),e); } @@ -99,6 +108,17 @@ public Optional runOnClientThreadOptional(Callable method) { } } + private void logClientThreadTimeout() { + long now = System.nanoTime(); + long last = lastTimeoutWarningNanos.get(); + if ((last == 0 || now - last >= TIMEOUT_WARNING_INTERVAL_NANOS) && lastTimeoutWarningNanos.compareAndSet(last, now)) { + log.warn("Client thread did not run task within {}ms (caller thread {}); further timeouts suppressed for {}s", + clientThreadTimeoutMillis, Thread.currentThread().getName(), TimeUnit.NANOSECONDS.toSeconds(TIMEOUT_WARNING_INTERVAL_NANOS)); + } else { + log.debug("Client thread did not run task within {}ms (caller thread {})", clientThreadTimeoutMillis, Thread.currentThread().getName()); + } + } + /** * Run a method on the client thread, returning the result. * @param method diff --git a/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java b/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java index 505b44611ab..aaffe78ba3d 100644 --- a/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java +++ b/runelite-client/src/main/java/net/runelite/client/config/ConfigManager.java @@ -156,7 +156,7 @@ public void switchProfile(ConfigProfile newProfile) // Ensure existing config is saved sendConfig(); - log.info("Switching profile to: {} ({})", newProfile.getName(), newProfile.getId()); + log.info("Switching profile to: {}", newProfile.getId()); // sync the latest config revision from the server if (sessionManager.getAccountSession() != null && newProfile.isSync()) @@ -277,7 +277,7 @@ public void onSessionClose(SessionClose sessionClose) if (profile.isSync()) { - log.info("Active remote profile '{}' lost due to session close, converting to a local profile.", profile.getName()); + log.info("Active remote profile {} lost due to session close, converting to a local profile.", profile.getId()); profile.setSync(false); profile.setRev(-1L); } @@ -656,7 +656,7 @@ public void load() continue; } - log.info("Profile '{}' (sync: {}, active: {}, id: {})", p.getName(), p.isSync(), p.isActive(), p.getId()); + log.info("Profile {} (sync: {}, active: {})", p.getId(), p.isSync(), p.isActive()); } if (rsProfile == null) @@ -728,7 +728,7 @@ public void load() if (profile != null) { - log.info("Using profile: {} ({})", profile.getName(), profile.getId()); + log.info("Using profile: {}", profile.getId()); } else { @@ -740,7 +740,7 @@ public void load() profile.setActive(true); } - log.info("Creating profile: {} ({})", profile.getName(), profile.getId()); + log.info("Creating profile: {}", profile.getId()); } // synced profautoogile need to be fetched if outdated @@ -780,7 +780,7 @@ private void mergeRemoteProfiles(List remoteProfiles) if (migrating && remoteProfile.getId() == 0L) { - log.info("Using remote profile {} as the active profile", profile.getName()); + log.info("Using remote profile {} as the active profile", profile.getId()); profile.setActive(true); } } @@ -803,7 +803,7 @@ private void mergeRemoteProfiles(List remoteProfiles) continue; } - log.warn("Lost remote profile for '{}'", localProfile.getName()); + log.warn("Lost remote profile for {}", localProfile.getId()); // convert the profile to a non-synced profile localProfile.setSync(false); localProfile.setRev(-1); @@ -837,11 +837,11 @@ private void syncRemote(ProfileManager.Lock lock, ConfigProfile profile, List b.bind(HunterPlugin.class).toProvider(Providers.of(this)); + } + @Override protected void startUp() { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java index 318d34d1886..e114fc42415 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPlugin.java @@ -191,17 +191,13 @@ public void onConfigChanged(ConfigChanged ev) } else if (ev.getKey().equals("hideButton")) { - SwingUtilities.invokeLater(() -> + clientThread.invokeLater(() -> { - if (!config.hideButton()) - { - clientToolbar.addNavigation(navButton); - } - else + Player lp = client.getLocalPlayer(); + boolean inRegion = lp != null && lp.getWorldLocation().getRegionID() == REGION; + SwingUtilities.invokeLater(() -> { - Player lp = client.getLocalPlayer(); - boolean inRegion = lp != null && lp.getWorldLocation().getRegionID() == REGION; - if (inRegion) + if (!config.hideButton() || inRegion) { clientToolbar.addNavigation(navButton); } @@ -209,17 +205,20 @@ else if (ev.getKey().equals("hideButton")) { clientToolbar.removeNavigation(navButton); } - } + }); }); } else if (ev.getKey().equals("showTargetHintArrow")) { - if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation().getRegionID() != REGION) + clientThread.invokeLater(() -> { - return; - } + if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation().getRegionID() != REGION) + { + return; + } - updateBookcaseHintArrow(); + updateBookcaseHintArrow(); + }); } } 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 840cd92abdc..d8b98fdfee2 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 @@ -171,6 +171,8 @@ public class Microbot { private static TooltipManager tooltipManager; private static ScheduledFuture xpSchedulorFuture; 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; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -374,7 +376,8 @@ public static boolean hopToWorld(int worldNumber) { log.error("Can't hop world, already trying to hop"); return false; } - boolean hopIssued = Microbot.getClientThread().runOnClientThreadOptional(() -> { + 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; @@ -400,6 +403,7 @@ public static boolean hopToWorld(int worldNumber) { if (rsWorld == null) return false; quickHopTargetWorld = rsWorld; + target[0] = rsWorld; rsWorld.setActivity(newWorld.getActivity()); rsWorld.setAddress(newWorld.getAddress()); rsWorld.setId(newWorld.getId()); @@ -407,12 +411,21 @@ public static boolean hopToWorld(int worldNumber) { rsWorld.setLocation(newWorld.getLocation()); rsWorld.setTypes(WorldUtil.toWorldTypes(newWorld.getTypes())); - Microbot.getClient().openWorldHopper(); - Microbot.getClient().hopToWorld(rsWorld); - quickHopTargetWorld = null; return true; }).orElse(false); - if (!hopIssued) { + if (target[0] != null) { + boolean hopIssued; + try { + hopIssued = prepared && hopViaWorldSwitcher(target[0], WORLD_SWITCHER_LOAD_TIMEOUT_MS); + } finally { + quickHopTargetWorld = null; + } + if (prepared && !hopIssued) { + log.warn("World switcher did not load in time, could not hop to world {}", worldNumber); + return false; + } + } + if (!prepared) { log.error("Failed to hop to world {}", worldNumber); return false; } @@ -421,7 +434,6 @@ public static boolean hopToWorld(int worldNumber) { // wait inside it is a no-op and the success check fires before the server has // even processed the request. That's the source of "Failed to hop" spam. if (Microbot.getClient().getWorld() != worldNumber) { - sleep(600); sleepUntil(() -> Microbot.isHopping() || Microbot.getClient().getWorld() == worldNumber || Rs2Widget.getWidget(193, 0) != null, 5000); @@ -452,6 +464,32 @@ public static boolean hopToWorld(int worldNumber) { return success; } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { + if (!isWorldSwitcherLoaded() + && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { + return false; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + if (Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) == null) { + return false; + } + Microbot.getClient().hopToWorld(rsWorld); + return true; + }).orElse(false); + } + + private static void openWorldSwitcher() { + Microbot.getClientThread().runOnClientThreadOptional(() -> { + Microbot.getClient().openWorldHopper(); + return true; + }); + } + + private static boolean isWorldSwitcherLoaded() { + return Microbot.getClientThread().runOnClientThreadOptional( + () -> Microbot.getClient().getWidget(InterfaceID.Worldswitcher.BUTTONS) != null).orElse(false); + } + public static void showMessage(String message) { try { Runnable messageRunnable = () -> @@ -464,6 +502,9 @@ public static void showMessage(String message) { } else { SwingUtilities.invokeAndWait(messageRunnable); } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while displaying message {}", message); } catch (Exception ex) { log.error("Error displaying message {}:", message, ex); } @@ -487,6 +528,9 @@ public static void showMessage(String message, int disposeTime) { } else { SwingUtilities.invokeAndWait(messageRunnable); } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while displaying message {}", message); } catch (Exception ex) { log.error("Error displaying message {}:", message, ex); } @@ -714,7 +758,9 @@ public static void log(String message, Level level, Exception ex) { log.warn(message); break; case ERROR: - if (ex != null) { + if (isInterruption(ex)) { + log.debug(message, ex); + } else if (ex != null) { log.error(message, ex); } else { log.error(message); @@ -729,6 +775,16 @@ public static void log(String message, Level level, Exception ex) { } } + static boolean isInterruption(Throwable ex) { + Throwable t = ex; + for (int depth = 0; t != null && depth < 64; depth++, t = t.getCause() == t ? null : t.getCause()) { + if (t instanceof InterruptedException) { + return true; + } + } + return false; + } + /** * Opens a pop‑up interface with the specified title and description. *

@@ -841,7 +897,7 @@ public static Injector getInjector() { * This method filters the active plugins managed by the `pluginManager` to include only those whose * package name contains "microbot" (case-insensitive). It further excludes certain plugins based on * their class names, such as "QuestHelperPlugin", "MInventorySetupsPlugin", "MicrobotPlugin", - * "ShortestPathPlugin", "AntibanPlugin", and "ExamplePlugin". + * "ShortestPathPlugin", and "AntibanPlugin". * * @return a list of active plugins belonging to the "microbot" package, excluding the specified plugins. */ @@ -852,8 +908,7 @@ public static List getActiveMicrobotPlugins() { && !x.getClass().getSimpleName().equalsIgnoreCase("MInventorySetupsPlugin") && !x.getClass().getSimpleName().equalsIgnoreCase("MicrobotPlugin") && !x.getClass().getSimpleName().equalsIgnoreCase("ShortestPathPlugin") - && !x.getClass().getSimpleName().equalsIgnoreCase("AntibanPlugin") - && !x.getClass().getSimpleName().equalsIgnoreCase("ExamplePlugin")) + && !x.getClass().getSimpleName().equalsIgnoreCase("AntibanPlugin")) .collect(Collectors.toList()); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java index 97d017fab75..4e2e4105b1c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotApi.java @@ -127,12 +127,14 @@ public void onFailure(Call call, IOException e) @Override public void onResponse(Call call, Response response) { - if (!response.isSuccessful()) + boolean successful = response.isSuccessful(); + int code = response.code(); + response.close(); + if (!successful) { - log.debug("Error telemetry upload rejected: HTTP {}", response.code()); + log.debug("Error telemetry upload rejected: HTTP {}", code); onRejected.run(); } - response.close(); } }); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java index ff1bc11d377..c91d61c90d5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java @@ -14,11 +14,13 @@ import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import org.jetbrains.annotations.NotNull; -import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicBoolean; /** * Base class for Microbot automation scripts. @@ -26,7 +28,15 @@ */ @Slf4j public abstract class Script extends Global implements IScript { - protected ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10, + private final AtomicBoolean startAnnounced = new AtomicBoolean(); + + private void announceStart() { + if (Microbot.getEventBus() != null && startAnnounced.compareAndSet(false, true)) { + Microbot.getEventBus().post(new ScriptStarted(this)); + } + } + + protected ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(10, new ThreadFactory() { private final AtomicInteger threadNumber = new AtomicInteger(1); @Override @@ -36,7 +46,22 @@ public Thread newThread(@NotNull Runnable r) { t.setDaemon(true); return t; } - }); + }) { + @Override + public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, + long delay, TimeUnit unit) { + // Announce before a zero-delay loop can report a terminal supply failure. + announceStart(); + return super.scheduleWithFixedDelay(command, initialDelay, delay, unit); + } + + @Override + public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, + long period, TimeUnit unit) { + announceStart(); + return super.scheduleAtFixedRate(command, initialDelay, period, unit); + } + }; protected ScheduledFuture scheduledFuture; protected ScheduledFuture mainScheduledFuture; @@ -55,6 +80,7 @@ public boolean isRunning() { * Safe to call multiple times; no-ops if already shut down. */ public void shutdown() { + startAnnounced.set(false); ScriptHeartbeatRegistry.remove(this.getClass().getName()); if (mainScheduledFuture != null && !mainScheduledFuture.isDone()) { mainScheduledFuture.cancel(true); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java new file mode 100644 index 00000000000..f8b4e46ea51 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ScriptStarted.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot; + +import lombok.Value; + +/** Posted once per script run, when its first repeating task is scheduled. */ +@Value +public class ScriptStarted { + Script script; +} 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 29d713196e9..830b0c83d73 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 @@ -3,6 +3,8 @@ import com.google.inject.Provides; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; +import net.runelite.api.events.GameStateChanged; +import net.runelite.client.eventbus.Subscribe; import net.runelite.client.Notifier; import net.runelite.client.callback.ClientThread; import net.runelite.client.config.ConfigManager; @@ -11,6 +13,8 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.ScriptStarted; +import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerScript; import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; import javax.inject.Inject; @@ -19,6 +23,7 @@ @PluginDescriptor( name = PluginDescriptor.Mocrosoft + "AutoLogin", description = "Microbot autologin plugin", + version = "1.0.6", tags = {"account", "microbot", "login"}, enabledByDefault = false ) @@ -44,4 +49,21 @@ protected void shutDown() { accountSelectorScript.shutdown(); } + @Subscribe + public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { + accountSelectorScript.suppressAutoLogin(); + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) { + accountSelectorScript.observeSuppressionGameState(event.getGameState()); + } + + @Subscribe + public void onScriptStarted(ScriptStarted event) { + // Existing loops do not emit this event each tick. A new gameplay run opts back in. + if (event.getScript() instanceof AutoLoginScript || event.getScript() instanceof BreakHandlerScript) return; + accountSelectorScript.resumeAutoLogin(); + } + } 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 4f7211dc434..cb4ef480efc 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 @@ -6,13 +6,16 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.Script; import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerScript; +import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2State; import net.runelite.client.plugins.microbot.util.discord.Rs2Discord; import net.runelite.client.plugins.microbot.util.discord.models.DiscordEmbed; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.player.Rs2PlayerModel; +import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.concurrent.ThreadLocalRandom; @@ -40,6 +43,16 @@ static int computeRetryBackoffSeconds(int attemptsSoFar, int configuredBaseSecon return (int) Math.max(floor, jittered); } + static boolean isBreakHandlerOwningLogin() { + return BreakHandlerScript.isBreakActive() + || BreakHandlerScript.isMicroBreakActive() + || BreakHandlerV2State.isBreakActive(); + } + + static boolean canStartLogin(GameState gameState) { + return gameState == GameState.LOGIN_SCREEN; + } + // ban detection state public static boolean isBanned = false; private static String lastKnownPlayerName = ""; @@ -59,6 +72,34 @@ private enum LoginState { private Instant extendedSleepStartTime = null; private Instant lastLoginAttemptTime = null; private long lastExtendedSleepLoggedMinute = -1; + private boolean lockoutLogged = false; + private final AutoLoginSuppression suppression = new AutoLoginSuppression(); + private Clock clock = Clock.systemUTC(); + + void setClock(Clock clock) { + this.clock = clock; + } + + String getLoginStateName() { + return loginState.name(); + } + + int getRetryCount() { + return retryCount; + } + + void suppressAutoLogin() { + suppression.request(); + } + + void resumeAutoLogin() { + suppression.clear(); + } + + void observeSuppressionGameState(GameState gameState) { + suppression.observe(gameState); + } + public boolean run(AutoLoginConfig autoLoginConfig) { @@ -67,7 +108,7 @@ public boolean run(AutoLoginConfig autoLoginConfig) { mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { try { if (!super.run()) return; - if (BreakHandlerScript.isBreakActive() || BreakHandlerScript.isMicroBreakActive()) return; + if (isBreakHandlerOwningLogin()) return; // check for ban detection first checkForBan(); @@ -89,7 +130,11 @@ public boolean run(AutoLoginConfig autoLoginConfig) { /** * Main state machine for auto login processing. */ - private void processAutoLoginStateMachine(AutoLoginConfig config) { + void processAutoLoginStateMachine(AutoLoginConfig config) { + if (suppression.isSuppressed()) { + resetLoginState(); + return; + } switch (loginState) { case WAITING_FOR_LOGIN_SCREEN: handleWaitingForLoginScreenState(config); @@ -118,6 +163,14 @@ private void handleWaitingForLoginScreenState(AutoLoginConfig config) { return; } + if (!canStartLogin(LoginManager.getGameState())) { + return; + } + + if (waitForLoginLockout()) { + return; + } + log.info("Login screen detected, initiating login"); initiateLogin(config); transitionToState(LoginState.ATTEMPTING_LOGIN); @@ -135,20 +188,30 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { return; } + if (waitForLoginLockout()) { + lastLoginAttemptTime = Instant.now(clock); + loginWatchdogStartTime = null; + return; + } + // check login watchdog timeout if enabled if (config.enableLoginWatchdog() && loginWatchdogStartTime != null) { - long watchdogTime = Duration.between(loginWatchdogStartTime, Instant.now()).toMinutes(); + long watchdogTime = Duration.between(loginWatchdogStartTime, Instant.now(clock)).toMinutes(); if (watchdogTime >= config.loginWatchdogTimeout()) { log.warn("Login watchdog timeout reached after {} minutes, entering extended sleep", watchdogTime); - extendedSleepStartTime = Instant.now(); + extendedSleepStartTime = Instant.now(clock); transitionToState(LoginState.LOGIN_EXTENDED_SLEEP); return; } } + if (LoginManager.getGameState() != GameState.LOGIN_SCREEN) { + return; + } + // check if enough time has passed for retry — uses exponential backoff if (lastLoginAttemptTime != null) { - long timeSinceLastAttempt = Duration.between(lastLoginAttemptTime, Instant.now()).toSeconds(); + long timeSinceLastAttempt = Duration.between(lastLoginAttemptTime, Instant.now(clock)).toSeconds(); int backoff = computeRetryBackoffSeconds(retryCount, config.loginRetryDelay()); if (timeSinceLastAttempt < backoff) { return; @@ -159,7 +222,7 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { if (retryCount >= config.maxLoginRetries()) { if (config.enableLoginWatchdog()) { log.warn("Max login retries reached, entering extended sleep"); - extendedSleepStartTime = Instant.now(); + extendedSleepStartTime = Instant.now(clock); transitionToState(LoginState.LOGIN_EXTENDED_SLEEP); } else { log.warn("Max login retries reached, resetting retry count"); @@ -168,8 +231,7 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { return; } - // check if still on login screen - if (Microbot.getClient() != null && Microbot.getClient().getGameState() == GameState.LOGIN_SCREEN) { + if (Microbot.getClient() != null) { log.info("Retrying login attempt {} of {}", retryCount + 1, config.maxLoginRetries()); int currentLoginIndex = Microbot.getClient().getLoginIndex(); if (Microbot.getClient().getLoginIndex() == 3 || Microbot.getClient().getLoginIndex() == 24) { // you were disconnected from the server. @@ -199,13 +261,20 @@ private void handleAttemptingLoginState(AutoLoginConfig config) { // we have to find out other indexes that mean we cannot login initiateLogin(config); - } else { - + } + } - // not on login screen anymore, return to waiting - resetLoginState(); - transitionToState(LoginState.WAITING_FOR_LOGIN_SCREEN); + private boolean waitForLoginLockout() { + if (!LoginManager.checkLoginLockout()) { + lockoutLogged = false; + return false; + } + if (!lockoutLogged) { + log.warn("Too many login attempts - AutoLogin waiting {}s before the next attempt", + TimeUnit.MILLISECONDS.toSeconds(LoginLockout.getInstance().remainingMillis())); + lockoutLogged = true; } + return true; } /** @@ -221,13 +290,13 @@ private void handleLoginExtendedSleepState(AutoLoginConfig config) { } if (extendedSleepStartTime == null) { - extendedSleepStartTime = Instant.now(); + extendedSleepStartTime = Instant.now(clock); log.info("Extended sleep started for {} minutes", config.extendedSleepDuration()); return; } // check if extended sleep period is complete - long sleepTime = Duration.between(extendedSleepStartTime, Instant.now()).toMinutes(); + long sleepTime = Duration.between(extendedSleepStartTime, Instant.now(clock)).toMinutes(); if (sleepTime >= config.extendedSleepDuration()) { log.info("Extended sleep period completed after {} minutes, resuming login attempts", sleepTime); resetLoginState(); @@ -247,9 +316,10 @@ private void handleLoginExtendedSleepState(AutoLoginConfig config) { */ private void initiateLogin(AutoLoginConfig config) { try { + if (suppression.isSuppressed()) return; // start login watchdog if enabled and not already started if (config.enableLoginWatchdog() && loginWatchdogStartTime == null) { - loginWatchdogStartTime = Instant.now(); + loginWatchdogStartTime = Instant.now(clock); log.info("Login watchdog started for {} minutes", config.loginWatchdogTimeout()); } @@ -326,17 +396,20 @@ private void initiateLogin(AutoLoginConfig config) { } } + // A terminal stop can arrive during world selection. + if (suppression.isSuppressed()) return; + // perform login attempt and track retry state retryCount++; - lastLoginAttemptTime = Instant.now(); + lastLoginAttemptTime = Instant.now(clock); boolean loginInitiated; if (targetWorld != -1) { log.info("Attempting login to selected world: {} (attempt {})", targetWorld, retryCount); - loginInitiated = LoginManager.login(targetWorld); + loginInitiated = !suppression.isSuppressed() && LoginManager.login(targetWorld); } else { log.info("Using default login (current world or last used) (attempt {})", retryCount); - loginInitiated = LoginManager.login(); + loginInitiated = !suppression.isSuppressed() && LoginManager.login(); } if (!loginInitiated) { @@ -348,7 +421,7 @@ private void initiateLogin(AutoLoginConfig config) { } catch (Exception ex) { log.error("Error during intelligent login", ex); retryCount++; - lastLoginAttemptTime = Instant.now(); + lastLoginAttemptTime = Instant.now(clock); } } @@ -500,6 +573,7 @@ public void shutdown() { log.info("Auto login script shutting down"); resetLoginState(); + suppression.clear(); super.shutdown(); } 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 new file mode 100644 index 00000000000..255776e2797 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppression.java @@ -0,0 +1,57 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import net.runelite.api.GameState; + +/** Tracks the requested logout separately from a subsequent manual login. */ +final class AutoLoginSuppression { + private enum State { ALLOWED, AWAITING_LOGOUT, LOGGED_OUT } + + static final long AWAIT_LOGOUT_MILLIS = 15_000L; + private final LongSupplier clock; + private State state = State.ALLOWED; + private long requestedAt; + + AutoLoginSuppression() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime())); + } + + AutoLoginSuppression(LongSupplier clock) { + this.clock = clock; + } + + synchronized void request() { + expirePendingRequest(); + if (state == State.ALLOWED) { + requestedAt = clock.getAsLong(); + state = State.AWAITING_LOGOUT; + } + } + + synchronized void observe(GameState gameState) { + expirePendingRequest(); + if (state == State.AWAITING_LOGOUT && gameState == GameState.LOGIN_SCREEN) { + state = State.LOGGED_OUT; + } else if (state == State.LOGGED_OUT && gameState == GameState.LOGGED_IN) { + state = State.ALLOWED; + } + } + + synchronized boolean isSuppressed() { + // Also expire during polling when no game-state transition has occurred. + expirePendingRequest(); + return state != State.ALLOWED; + } + + synchronized void clear() { + state = State.ALLOWED; + } + + private void expirePendingRequest() { + if (state == State.AWAITING_LOGOUT + && clock.getAsLong() - requestedAt >= AWAIT_LOGOUT_MILLIS) { + state = State.ALLOWED; + } + } +} 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 new file mode 100644 index 00000000000..d157c74db32 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionRequest.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot.accountselector; + +/** + * Scripts post this event before a terminal logout to prevent automatic re-login. + * AutoLogin resumes when another script starts, after a manual login, or on AutoLogin restart. + * Ordinary logouts must not post this event. + */ +public final class AutoLoginSuppressionRequest { +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java index 607bc2c9904..9ad807146db 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java @@ -2,6 +2,9 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializer; import com.google.inject.Provides; import com.sun.net.httpserver.HttpServer; import lombok.extern.slf4j.Slf4j; @@ -23,7 +26,9 @@ import java.nio.file.Paths; import java.nio.file.attribute.PosixFilePermissions; import java.util.Arrays; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -56,12 +61,34 @@ public class AgentServerPlugin extends Plugin { private net.runelite.client.plugins.microbot.agentserver.uds.UdsHttpServer udsServer; private ExecutorService executor; private Thread shutdownHook; - private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private final Gson gson = createGson(); private java.util.concurrent.ScheduledExecutorService stealthScheduler; private volatile boolean stealthActive = false; private static final long STEALTH_IDLE_GRACE_MS = 20_000; + static Gson createGson() { + JsonSerializer> collectionSerializer = (src, type, ctx) -> { + JsonArray array = new JsonArray(); + for (Object element : src) { + array.add(ctx.serialize(element)); + } + return array; + }; + JsonSerializer> mapSerializer = (src, type, ctx) -> { + JsonObject object = new JsonObject(); + for (Map.Entry entry : src.entrySet()) { + object.add(String.valueOf(entry.getKey()), ctx.serialize(entry.getValue())); + } + return object; + }; + return new GsonBuilder() + .setPrettyPrinting() + .registerTypeHierarchyAdapter(Collection.class, collectionSerializer) + .registerTypeHierarchyAdapter(Map.class, mapSerializer) + .create(); + } + private static Path defaultUdsSocketPath() { return Paths.get(System.getProperty("user.home"), ".runelite", ".agent.sock"); } 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 2b0334eb45c..eeab31b4a88 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.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; import java.io.IOException; @@ -22,12 +23,14 @@ public class LoginHandler extends AgentHandler { private static final int LOGIN_INDEX_BANNED = 14; private static final int LOGIN_INDEX_DISCONNECTED = 24; private static final int LOGIN_INDEX_NON_MEMBER = 34; + private static final int LOGIN_INDEX_TOO_MANY_ATTEMPTS = LoginLockout.LOGIN_INDEX_TOO_MANY_ATTEMPTS; private static final Set FATAL_LOGIN_INDICES = Set.of( LOGIN_INDEX_AUTH_FAILURE, LOGIN_INDEX_INVALID_CREDENTIALS, LOGIN_INDEX_BANNED, - LOGIN_INDEX_NON_MEMBER + LOGIN_INDEX_NON_MEMBER, + LOGIN_INDEX_TOO_MANY_ATTEMPTS ); private static final int DEFAULT_TIMEOUT_SECONDS = 30; @@ -72,6 +75,10 @@ private Map buildStatusMap() { result.put("loggedIn", Microbot.isLoggedIn()); result.put("gameState", gameState.name()); result.put("loginAttemptActive", LoginManager.isLoginAttemptActive()); + long loginCooldownMs = LoginLockout.getInstance().remainingMillis(); + if (loginCooldownMs > 0) { + result.put("loginCooldownMs", loginCooldownMs); + } if (Microbot.isLoggedIn()) { long durationMs = LoginManager.getLoginDuration().toMillis(); @@ -152,6 +159,11 @@ private void handleLogin(HttpExchange exchange) throws IOException { return; } + if (!loginInitiated && LoginLockout.getInstance().isActive()) { + sendLoginCooldown(exchange); + return; + } + if (!loginInitiated) { Map result = new LinkedHashMap<>(); result.put("success", false); @@ -173,6 +185,12 @@ private void handleLogin(HttpExchange exchange) throws IOException { LoginResult loginResult = waitForLoginResult(timeoutSeconds); + if (!loginResult.success && LoginLockout.isLockoutLoginIndex(loginResult.loginIndex)) { + LoginManager.checkLoginLockout(); + sendLoginCooldown(exchange); + return; + } + Map result = new LinkedHashMap<>(); result.put("success", loginResult.success); result.put("message", loginResult.message); @@ -189,6 +207,15 @@ private void handleLogin(HttpExchange exchange) throws IOException { sendJson(exchange, loginResult.success ? 200 : 401, result); } + private void sendLoginCooldown(HttpExchange exchange) throws IOException { + Map result = new LinkedHashMap<>(); + result.put("success", false); + result.put("message", "Too many login attempts - login paused until the cooldown ends"); + result.put("loginIndex", LOGIN_INDEX_TOO_MANY_ATTEMPTS); + result.put("loginCooldownMs", LoginLockout.getInstance().remainingMillis()); + sendJson(exchange, 429, result); + } + private LoginResult waitForLoginResult(int timeoutSeconds) { long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L); @@ -260,6 +287,8 @@ private static String describeLoginIndex(int loginIndex) { return "Disconnected from server"; case LOGIN_INDEX_NON_MEMBER: return "Non-member account cannot login to members world"; + case LOGIN_INDEX_TOO_MANY_ATTEMPTS: + return "Too many login attempts - wait before retrying"; default: return null; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ProfileHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ProfileHandler.java index 54f9f289c11..5cd1fa76f60 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ProfileHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/ProfileHandler.java @@ -155,7 +155,7 @@ private void handleSwitch(HttpExchange exchange) throws IOException { try { configManager.switchProfile(targetProfile); } catch (Exception e) { - log.error("Failed to switch profile to '{}'", targetProfile.getName(), e); + log.error("Failed to switch profile to {}", targetProfile.getId(), e); sendJson(exchange, 500, errorResponse("Profile switch failed: " + e.getMessage())); return; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java index d4e74dd4602..5ed809726b5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryable.java @@ -59,7 +59,10 @@ public Q within(int distance) { } this.source = this.source - .filter(o -> o.getWorldLocation().distanceTo(playerLoc) <= distance); + .filter(o -> { + WorldPoint loc = o.getWorldLocation(); + return loc != null && loc.distanceTo(playerLoc) <= distance; + }); return (Q) this; } @@ -73,7 +76,10 @@ public Q within(WorldPoint anchor, int distance) { } this.source = this.source - .filter(o -> o.getWorldLocation().distanceTo(anchor) <= distance); + .filter(o -> { + WorldPoint loc = o.getWorldLocation(); + return loc != null && loc.distanceTo(anchor) <= distance; + }); return (Q) this; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java index 79ed15df01d..400ebc18789 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/actor/Rs2ActorModel.java @@ -24,6 +24,9 @@ public class Rs2ActorModel implements Actor @Override public WorldView getWorldView() { + if (actor == null) { + return null; + } return Microbot.getClientThread().invoke(actor::getWorldView); } @@ -71,6 +74,9 @@ public int getHealthScale() @Override public WorldPoint getWorldLocation() { + if (actor == null) { + return null; + } return Microbot.getClientThread().invoke(() -> { WorldView worldView = actor.getWorldView(); if (worldView != null && !worldView.isTopLevel()) { @@ -83,6 +89,9 @@ public WorldPoint getWorldLocation() @Override public LocalPoint getLocalLocation() { + if (actor == null) { + return null; + } return actor.getLocalLocation(); } @@ -101,6 +110,9 @@ public int getCurrentOrientation() @Override public int getAnimation() { + if (actor == null) { + return -1; + } return actor.getAnimation(); } @@ -451,6 +463,9 @@ public long getHash() } public WorldPoint projectActorLocationToMainWorld() { + if (actor == null) { + return null; + } WorldPoint actorLocation = actor.getWorldLocation(); WorldView wv = actor.getWorldView(); LocalPoint localPoint = LocalPoint.fromWorld(wv, actorLocation); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java index bf25bae6c49..2c8a29e6b74 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/tileitem/models/Rs2TileItemModel.java @@ -107,11 +107,25 @@ public String getName() { } public WorldPoint getWorldLocation() { - return tile.getWorldLocation(); + if (tile == null) { + return null; + } + try { + return tile.getWorldLocation(); + } catch (NullPointerException ex) { + return null; + } } public LocalPoint getLocalLocation() { - return tile.getLocalLocation(); + if (tile == null) { + return null; + } + try { + return tile.getLocalLocation(); + } catch (NullPointerException ex) { + return null; + } } @Override diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java index f88e8d5fdae..0d6dc205603 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/BreakHandlerV2Script.java @@ -10,6 +10,7 @@ import net.runelite.client.plugins.microbot.util.discord.Rs2Discord; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.security.LoginLockout; import net.runelite.client.plugins.microbot.util.security.LoginManager; import net.runelite.client.plugins.microbot.util.world.Rs2WorldUtil; import net.runelite.client.ui.ClientUI; @@ -58,6 +59,7 @@ public BreakHandlerV2Script() { private int preBreakWorld = -1; private ConfigProfile activeProfile; private boolean unexpectedLogoutDetected = false; + private boolean loginLockoutLogged = false; private String originalWindowTitle = ""; private boolean pluginStopTriggered = false; private boolean pluginRestartPending = false; @@ -89,7 +91,7 @@ public BreakHandlerV2Script() { private static final int MAX_SAFETY_CHECK_ATTEMPTS = 60; private static final int SAFETY_CHECK_DELAY_MS = 5000; // 5 seconds between checks - public static String version = "2.0.7"; + public static String version = "2.0.8"; /** * Run the break handler script @@ -431,6 +433,10 @@ private void handleLoginRequested() { return; } + if (waitForLoginLockout()) { + return; + } + // Check retry limit (max 10 attempts) if (loginRetryCount >= MAX_LOGIN_ATTEMPTS) { log.error("[BreakHandlerV2] Max login attempts ({}) reached", MAX_LOGIN_ATTEMPTS); @@ -458,6 +464,10 @@ private void handleLoginRequested() { sleep(5000); } + if (waitForLoginLockout()) { + return; + } + // Select world based on configuration int targetWorld = selectWorld(); @@ -487,6 +497,22 @@ private void handleLoginRequested() { } } + private boolean waitForLoginLockout() { + if (!LoginManager.checkLoginLockout()) { + loginLockoutLogged = false; + return false; + } + if (!loginLockoutLogged) { + long remainingSeconds = TimeUnit.MILLISECONDS.toSeconds(LoginLockout.getInstance().remainingMillis()); + log.warn("[BreakHandlerV2] Too many login attempts - waiting {}s before logging in again", remainingSeconds); + sendDiscordNotification("Login Paused", + "Too many login attempts. Waiting " + (remainingSeconds / 60 + 1) + " minutes before retrying."); + loginLockoutLogged = true; + } + loginRetryCount = Math.min(Math.max(loginRetryCount, INITIAL_FAST_RETRIES), MAX_LOGIN_ATTEMPTS - 1); + return true; + } + /** * Calculate exponential backoff delay for login retries * First 3 attempts: 5s delay @@ -516,6 +542,11 @@ private void handleLoggingIn() { return; } + if (LoginManager.checkLoginLockout()) { + transitionToState(BreakHandlerV2State.LOGIN_REQUESTED); + return; + } + // Check for timeout (10 seconds) if (loginAttemptTime != null && Instant.now().isAfter(loginAttemptTime.plusSeconds(10))) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java index 98048aeb466..e8b6583bf6c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/breakhandlerv2/MicrobotPluginChoice.java @@ -5,7 +5,6 @@ import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerPlugin; import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2Plugin; -import net.runelite.client.plugins.microbot.example.ExamplePlugin; import net.runelite.client.plugins.microbot.inventorysetups.MInventorySetupsPlugin; import net.runelite.client.plugins.microbot.mouserecorder.MouseMacroRecorderPlugin; import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; @@ -22,8 +21,7 @@ public enum MicrobotPluginChoice { MOUSE_MACRO_RECORDER("Mouse Macro Recorder", MouseMacroRecorderPlugin.class), INVENTORY_SETUPS("Inventory Setups", MInventorySetupsPlugin.class), BREAK_HANDLER("BreakHandler (v1)", BreakHandlerPlugin.class), - BREAK_HANDLER_V2("BreakHandler V2", BreakHandlerV2Plugin.class), - EXAMPLE("Example Plugin", ExamplePlugin.class); + BREAK_HANDLER_V2("BreakHandler V2", BreakHandlerV2Plugin.class); private final String displayName; private final Class pluginClass; 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 ad4424a41de..b328274e18f 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 @@ -89,6 +89,10 @@ protected void append(ILoggingEvent event) } IThrowableProxy root = rootCause(event.getThrowableProxy()); + if (root != null && InterruptedException.class.getName().equals(root.getClassName())) + { + return; + } List frames = frames(root); String fingerprint = root == null ? event.getLoggerName() + "|" + scrub(event.getMessage()).replaceAll("\\d+", "#") @@ -153,7 +157,15 @@ private void send() payload.addProperty("osName", System.getProperty("os.name")); payload.addProperty("osArch", System.getProperty("os.arch")); payload.add("errors", errors); - microbotApi.submitErrors(payload, () -> requeue(errors)); + try + { + microbotApi.submitErrors(payload, () -> requeue(errors)); + } + catch (RuntimeException e) + { + requeue(errors); + throw e; + } } public void rememberPlayerName(String name) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java deleted file mode 100644 index 9c6c48cf681..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExamplePlugin.java +++ /dev/null @@ -1,45 +0,0 @@ -package net.runelite.client.plugins.microbot.example; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.client.eventbus.Subscribe; -import net.runelite.client.events.ConfigChanged; -import net.runelite.client.plugins.Plugin; -import net.runelite.client.plugins.PluginDescriptor; -import net.runelite.client.ui.overlay.OverlayManager; - -import javax.inject.Inject; -import java.awt.*; - -@PluginDescriptor( - name = PluginDescriptor.Default + "Example Plugin", - description = "Performance test for GameObject composition retrieval", - tags = {"performance", "microbot", "test", "gameobject"}, - enabledByDefault = false -) -@Slf4j -public class ExamplePlugin extends Plugin { - @Inject - ExampleScript exampleScript; - @Inject - ExampleScriptOverlay exampleScriptOverlay; - @Inject - OverlayManager overlayManager; - - - @Override - protected void startUp() throws AWTException { - overlayManager.add(exampleScriptOverlay); - exampleScript.run(); - } - - @Override - protected void shutDown() { - overlayManager.remove(exampleScriptOverlay); - exampleScript.shutdown(); - } - - // on settings change - @Subscribe - public void onConfigChanged(final ConfigChanged event) { - } -} \ No newline at end of file diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java deleted file mode 100644 index 29cfb1c68c0..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScript.java +++ /dev/null @@ -1,377 +0,0 @@ -package net.runelite.client.plugins.microbot.example; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.*; -import net.runelite.api.coords.LocalPoint; -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.Script; -import net.runelite.client.plugins.microbot.api.npc.Rs2NpcCache; -import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; -import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; -import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; -import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; -import net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem; -import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; -import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; -import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; -import net.runelite.client.plugins.microbot.util.player.Rs2Player; -import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; -import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; -import net.runelite.client.plugins.skillcalculator.skills.MagicAction; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; - -@Slf4j -public class ExampleScript extends Script { - - private final List results = new ArrayList<>(); - - public boolean run() { - results.clear(); - mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { - try { - if (!Microbot.isLoggedIn()) return; - if (!super.run()) return; - - log.info("========================================"); - log.info(" MICROBOT POST-MERGE SMOKE TEST"); - log.info("========================================"); - - checkClientState(); - checkPlayerState(); - checkWorldViewAndThreading(); - checkNpcCache(); - checkTileObjectCache(); - checkGroundItemCache(); - checkPlayerCache(); - checkInventory(); - checkEquipment(); - checkWidgets(); - checkWalker(); - checkDialogue(); - checkLooting(); - checkHomeTeleport(); - - printSummary(); - shutdown(); - - } catch (Exception ex) { - log.error("[SmokeTest] Unexpected top-level error: ", ex); - shutdown(); - } - }, 0, 600, TimeUnit.MILLISECONDS); - - return true; - } - - private void check(String name, Runnable test) { - try { - test.run(); - results.add(new CheckResult(name, true, null)); - log.info("[PASS] {}", name); - } catch (Exception e) { - results.add(new CheckResult(name, false, e.getMessage())); - log.error("[FAIL] {} - {}", name, e.getMessage()); - } - } - - private void checkClientState() { - log.info("--- Client & Login State ---"); - - check("Microbot.isLoggedIn()", () -> { - assertTrue(Microbot.isLoggedIn(), "expected logged in"); - }); - - check("Client.getLocalPlayer() not null", () -> { - assertNotNull(Microbot.getClient().getLocalPlayer(), "local player is null"); - }); - - check("GameState is LOGGED_IN", () -> { - GameState state = Microbot.getClient().getGameState(); - assertTrue(state == GameState.LOGGED_IN || state == GameState.LOADING, - "expected LOGGED_IN or LOADING, got " + state); - }); - } - - private void checkPlayerState() { - log.info("--- Player State ---"); - - check("Rs2Player.getWorldLocation() valid", () -> { - WorldPoint wp = Rs2Player.getWorldLocation(); - assertNotNull(wp, "world location is null"); - assertTrue(wp.getX() > 0 && wp.getY() > 0, "world location coords are zero: " + wp); - }); - - check("Rs2Player.getLocalLocation() not null", () -> { - LocalPoint lp = Rs2Player.getLocalLocation(); - assertNotNull(lp, "local location is null"); - }); - - check("Rs2Player.getHealthPercentage() in range", () -> { - double hp = Rs2Player.getHealthPercentage(); - assertTrue(hp >= 0 && hp <= 100, "health percentage out of range: " + hp); - }); - - check("Rs2Player.getRealSkillLevel(HITPOINTS) > 0", () -> { - int level = Rs2Player.getRealSkillLevel(Skill.HITPOINTS); - assertTrue(level > 0, "hitpoints level is " + level); - }); - - check("Rs2Player.isAnimating() callable", () -> { - Rs2Player.isAnimating(); - }); - - check("Rs2Player.isMoving() callable", () -> { - Rs2Player.isMoving(); - }); - - check("Rs2Player.isInCombat() callable", () -> { - Rs2Player.isInCombat(); - }); - } - - private void checkWorldViewAndThreading() { - log.info("--- WorldView & Threading ---"); - - check("TopLevelWorldView not null", () -> { - WorldView wv = Microbot.getClient().getTopLevelWorldView(); - assertNotNull(wv, "top level world view is null"); - }); - - check("Player WorldView is top level", () -> { - Player player = Microbot.getClient().getLocalPlayer(); - assertNotNull(player, "local player is null"); - WorldView wv = Microbot.getClientThread().invoke(() -> player.getWorldView()); - assertNotNull(wv, "player world view is null"); - assertTrue(wv.isTopLevel(), "player world view is not top level"); - }); - - check("ClientThread.invoke() works", () -> { - String result = Microbot.getClientThread().invoke(() -> "ok"); - assertTrue("ok".equals(result), "invoke returned unexpected value: " + result); - }); - } - - private void checkNpcCache() { - log.info("--- NPC Cache ---"); - - check("Rs2NpcCache.getStream() not null", () -> { - Stream stream = Microbot.getRs2NpcCache().getStream(); - assertNotNull(stream, "npc stream is null"); - }); - - check("Rs2NpcCache stream has entries", () -> { - long count = Microbot.getRs2NpcCache().getStream().count(); - assertTrue(count > 0, "no NPCs in cache (are there NPCs nearby?)"); - }); - - check("First NPC has valid world location", () -> { - Rs2NpcModel npc = Microbot.getRs2NpcCache().getStream().findFirst().orElse(null); - assertNotNull(npc, "no NPC found"); - WorldPoint wp = npc.getWorldLocation(); - assertNotNull(wp, "NPC world location is null"); - }); - - check("Rs2NpcCache.query().nearest() works", () -> { - Microbot.getRs2NpcCache().query().nearest(); - }); - } - - private void checkTileObjectCache() { - log.info("--- Tile Object Cache ---"); - - check("Rs2TileObjectCache.getStream() not null", () -> { - Stream stream = Microbot.getRs2TileObjectCache().getStream(); - assertNotNull(stream, "tile object stream is null"); - }); - - check("Rs2TileObjectCache stream has entries", () -> { - long count = Microbot.getRs2TileObjectCache().getStream().count(); - assertTrue(count > 0, "no tile objects in cache"); - }); - - check("First TileObject has valid ID", () -> { - Rs2TileObjectModel obj = Microbot.getRs2TileObjectCache().getStream().findFirst().orElse(null); - assertNotNull(obj, "no tile object found"); - assertTrue(obj.getId() > 0, "tile object ID is " + obj.getId()); - }); - - check("Rs2TileObjectCache.query().nearest() works", () -> { - Microbot.getRs2TileObjectCache().query().nearest(); - }); - } - - private void checkGroundItemCache() { - log.info("--- Ground Item Cache ---"); - - check("Rs2TileItemCache.getStream() callable", () -> { - Microbot.getRs2TileItemCache().getStream(); - }); - } - - private void checkPlayerCache() { - log.info("--- Player Cache ---"); - - check("Rs2PlayerCache.getStream() callable", () -> { - Microbot.getRs2PlayerCache().getStream(); - }); - } - - private void checkInventory() { - log.info("--- Inventory ---"); - - check("Rs2Inventory.all() returns list", () -> { - List items = Rs2Inventory.all(); - assertNotNull(items, "inventory all() returned null"); - }); - - check("Rs2Inventory.count() >= 0", () -> { - int count = Rs2Inventory.count(); - assertTrue(count >= 0, "inventory count is negative: " + count); - }); - - check("Rs2Inventory.isFull() callable", () -> { - Rs2Inventory.isFull(); - }); - - check("Rs2Inventory.isEmpty() callable", () -> { - Rs2Inventory.isEmpty(); - }); - } - - private void checkEquipment() { - log.info("--- Equipment ---"); - - check("Rs2Equipment.equipment() callable", () -> { - Microbot.getClientThread().invoke(() -> Rs2Equipment.equipment()); - }); - - check("Rs2Equipment.isWearing() callable", () -> { - Rs2Equipment.isWearing(); - }); - } - - private void checkWidgets() { - log.info("--- Widgets ---"); - - check("Rs2Widget.getWidget(149, 0) callable", () -> { - Rs2Widget.getWidget(149, 0); - }); - } - - private void checkWalker() { - log.info("--- Walker ---"); - - check("Rs2Walker.canReach(own location) returns true", () -> { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - assertNotNull(playerLoc, "player location is null"); - boolean reachable = Rs2Walker.canReach(playerLoc); - assertTrue(reachable, "player cannot reach own tile"); - }); - } - - private void checkDialogue() { - log.info("--- Dialogue ---"); - - check("Rs2Dialogue.isInDialogue() callable", () -> { - Rs2Dialogue.isInDialogue(); - }); - } - - private void checkHomeTeleport() { - log.info("--- Home Teleport ---"); - - check("Home teleport starts animation", () -> { - boolean cast = Rs2Magic.cast(MagicAction.LUMBRIDGE_HOME_TELEPORT); - assertTrue(cast, "Rs2Magic.cast(LUMBRIDGE_HOME_TELEPORT) returned false"); - boolean animating = sleepUntil(Rs2Player::isAnimating, 3000); - assertTrue(animating, "player did not start home teleport animation"); - }); - } - - private void checkLooting() { - log.info("--- Looting ---"); - - Rs2ItemModel itemToDrop = Rs2Inventory.all().stream().findFirst().orElse(null); - if (itemToDrop == null) { - log.warn("[SKIP] Looting - inventory is empty, nothing to drop and loot"); - results.add(new CheckResult("Drop and loot item", false, "inventory is empty, cannot test looting")); - return; - } - - String itemName = itemToDrop.getName(); - int itemId = itemToDrop.getId(); - int countBefore = Rs2Inventory.count(itemId); - log.info("[Loot] Testing with item: {} (id={}, count={})", itemName, itemId, countBefore); - - check("Drop item from inventory", () -> { - boolean dropped = Rs2Inventory.drop(itemId); - assertTrue(dropped, "Rs2Inventory.drop() returned false"); - boolean disappeared = sleepUntil(() -> Rs2Inventory.count(itemId) < countBefore, 5000); - assertTrue(disappeared, "item did not leave inventory after drop"); - }); - - int countAfterDrop = Rs2Inventory.count(itemId); - - check("Loot item from ground", () -> { - boolean itemAppeared = sleepUntil(() -> Rs2GroundItem.exists(itemName, 5), 5000); - assertTrue(itemAppeared, "item did not appear on ground after drop"); - sleepUntil(() -> !Rs2Player.isAnimating() && !Rs2Player.isMoving(), 5000); - boolean looted = Rs2GroundItem.loot(itemName, 5); - assertTrue(looted, "Rs2GroundItem.loot() returned false"); - boolean pickedUp = sleepUntil(() -> Rs2Inventory.count(itemId) > countAfterDrop, 5000); - assertTrue(pickedUp, "item did not return to inventory after loot"); - }); - } - - private void printSummary() { - long passed = results.stream().filter(r -> r.passed).count(); - long failed = results.stream().filter(r -> !r.passed).count(); - long total = results.size(); - - log.info("========================================"); - log.info(" SMOKE TEST RESULTS: {}/{} PASSED", passed, total); - log.info("========================================"); - - if (failed > 0) { - log.warn("FAILURES:"); - results.stream() - .filter(r -> !r.passed) - .forEach(r -> log.warn(" [FAIL] {} - {}", r.name, r.error)); - } else { - log.info("All checks passed!"); - } - - log.info("========================================"); - } - - private static void assertTrue(boolean condition, String message) { - if (!condition) throw new AssertionError(message); - } - - private static void assertNotNull(Object obj, String message) { - if (obj == null) throw new AssertionError(message); - } - - private static class AssertionError extends RuntimeException { - AssertionError(String message) { - super(message); - } - } - - private static class CheckResult { - final String name; - final boolean passed; - final String error; - - CheckResult(String name, boolean passed, String error) { - this.name = name; - this.passed = passed; - this.error = error; - } - } -} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java deleted file mode 100644 index 55495887311..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/example/ExampleScriptOverlay.java +++ /dev/null @@ -1,88 +0,0 @@ -package net.runelite.client.plugins.microbot.example; - -import net.runelite.api.Client; -import net.runelite.api.Perspective; -import net.runelite.api.coords.LocalPoint; -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -import net.runelite.client.plugins.microbot.util.reachable.Rs2Reachable; -import net.runelite.client.ui.overlay.Overlay; -import net.runelite.client.ui.overlay.OverlayLayer; -import net.runelite.client.ui.overlay.OverlayPosition; -import net.runelite.client.ui.overlay.OverlayUtil; - -import javax.inject.Inject; -import java.awt.*; -import java.util.ArrayList; -import java.util.List; - -public class ExampleScriptOverlay extends Overlay { - - private static final Color REACHABLE_COLOR = new Color(0, 255, 0, 50); - private static final Color REACHABLE_BORDER_COLOR = new Color(0, 255, 0, 150); - - @Inject - private Client client; - - @Inject - public ExampleScriptOverlay() { - setPosition(OverlayPosition.DYNAMIC); - setLayer(OverlayLayer.ABOVE_SCENE); - } - - @Override - public Dimension render(Graphics2D graphics) { - if (!Microbot.isLoggedIn()) - { - return null; - } - - WorldPoint playerLocation = WorldPoint.fromLocalInstance(client, client.getLocalPlayer().getLocalLocation()); - if (playerLocation == null) - { - return null; - } - - // Get reachable tiles as packed ints - var reachablePacked = Rs2Reachable.getReachableTiles(playerLocation); - if (reachablePacked == null || reachablePacked.isEmpty()) - { - return null; - } - - // Convert packed ints back to world points in current plane/instance - List reachableWorldPoints = new ArrayList<>(); - for (int packed : reachablePacked) - { - WorldPoint wp = WorldPointUtil.unpackWorldPoint(packed); - if (wp.getPlane() == playerLocation.getPlane()) - { - reachableWorldPoints.add(wp); - } - } - - for (WorldPoint worldPoint : reachableWorldPoints) - { - LocalPoint localPoint = LocalPoint.fromWorld(client, worldPoint); - if (localPoint == null) - { - continue; - } - - Polygon poly = Perspective.getCanvasTilePoly(client, localPoint); - if (poly == null) - { - continue; - } - - graphics.setColor(REACHABLE_COLOR); - graphics.fillPolygon(poly); - - graphics.setColor(REACHABLE_BORDER_COLOR); - graphics.drawPolygon(poly); - } - - return null; - } -} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java index 140759df906..3593f69aab0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManager.java @@ -564,7 +564,6 @@ private Plugin instantiate(Collection scannedPlugins, Class claz } catch (com.google.common.util.concurrent.ExecutionError e) { // Guice/Guava wraps NoClassDefFoundError here Throwable cause = e.getCause(); - StartupRecovery.pluginFailed(clazz.getSimpleName(), clazz.getAnnotation(PluginDescriptor.class).version(), "load", e); if (cause instanceof NoClassDefFoundError) { log.error("Missing class while loading plugin {}: {}", clazz.getSimpleName(), cause.toString()); } else { @@ -575,11 +574,14 @@ private Plugin instantiate(Collection scannedPlugins, Class claz if (jar != null) { jar.delete(); } - } catch (Exception ex) { + throw new PluginInstantiationException(e); + } catch (ThreadDeath e) { + throw e; + } catch (Throwable ex) { log.error("Incompatible plugin found: " + clazz.getSimpleName()); - StartupRecovery.pluginFailed(clazz.getSimpleName(), clazz.getAnnotation(PluginDescriptor.class).version(), "load", ex); File jar = getPluginJarFile(plugin.getClass().getSimpleName()); jar.delete(); + throw new PluginInstantiationException(ex); } log.debug("Loaded plugin {}", clazz.getSimpleName()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java index 0186d534475..88488836032 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/inventorysetups/MInventorySetupsPlugin.java @@ -277,9 +277,6 @@ public void startUp() // get current version of the plugin using properties file generated by build.gradle try { -// final Properties props = new Properties(); -// InputStream is = MInventorySetupsPlugin.class.getResourceAsStream("/invsetups_version.txt"); -// props.load(is); this.currentVersion = "1.20.2"; } catch (Exception e) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java index 2768b5d8ce3..3fd62c956df 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java @@ -184,9 +184,9 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } } - if (getQuestHelperPlugin().getSelectedQuest() != null && !Microbot.getClientThread().runOnClientThreadOptional(() -> - getQuestHelperPlugin().getSelectedQuest().isCompleted()).orElse(null)) { - if (Rs2Widget.isWidgetVisible(ComponentID.DIALOG_OPTION_OPTIONS) && getQuestHelperPlugin().getSelectedQuest().getQuest().getId() != Quest.COOKS_ASSISTANT.getId() && !Rs2Bank.isOpen()) { + QuestHelper selectedQuest = getQuestHelperPlugin().getSelectedQuest(); + if (selectedQuest != null && !Microbot.getClientThread().runOnClientThreadOptional(selectedQuest::isCompleted).orElse(true)) { + if (Rs2Widget.isWidgetVisible(ComponentID.DIALOG_OPTION_OPTIONS) && selectedQuest.getQuest().getId() != Quest.COOKS_ASSISTANT.getId() && !Rs2Bank.isOpen()) { boolean hasOption = Rs2Dialogue.handleQuestOptionDialogueSelection(); //if there is no quest option in the dialogue, just click player location to remove // the dialogue to avoid getting stuck in an infinite loop of dialogues @@ -194,8 +194,8 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { if (Rs2Dialogue.acceptQuestStartDialogue()) { return; } - if (getQuestHelperPlugin().getSelectedQuest() != null && - getQuestHelperPlugin().getSelectedQuest().getQuest().getId() == Quest.IMP_CATCHER.getId() + if (selectedQuest != null && + selectedQuest.getQuest().getId() == Quest.IMP_CATCHER.getId() && Microbot.getClient().getTopLevelWorldView().getPlane() == 1) { Rs2Dialogue.keyPressForDialogueOption(1); // presses option 1 sleep(1200,1800); @@ -205,14 +205,14 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { return; } - if (getQuestHelperPlugin().getSelectedQuest() != null && - getQuestHelperPlugin().getSelectedQuest().getQuest().getId() == Quest.COOKS_ASSISTANT.getId() && + if (selectedQuest != null && + selectedQuest.getQuest().getId() == Quest.COOKS_ASSISTANT.getId() && Rs2Dialogue.isInDialogue()) { dialogueStartedStep = questStep; // Force this to be true for Cook's Assistant } - if (getQuestHelperPlugin().getSelectedQuest() != null && - getQuestHelperPlugin().getSelectedQuest().getQuest().getId() == Quest.PIRATES_TREASURE.getId() && + if (selectedQuest != null && + selectedQuest.getQuest().getId() == Quest.PIRATES_TREASURE.getId() && Rs2Dialogue.isInDialogue()) { dialogueStartedStep = questStep; } @@ -253,7 +253,7 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { * If we do not prioritize this, the script will think we are missing items */ if (questStep instanceof DetailedQuestStep && !(questStep instanceof NpcStep || questStep instanceof ObjectStep || questStep instanceof DigStep)) { - boolean result = applyDetailedQuestStep((DetailedQuestStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep().getActiveStep()); + boolean result = applyDetailedQuestStep((DetailedQuestStep) selectedQuest.getCurrentStep().getActiveStep()); if (result) { sleepUntil(() -> Rs2Player.isInteracting() || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Dialogue.isInDialogue(), 500); sleepUntil(() -> !Rs2Player.isInteracting() && !Rs2Player.isMoving() && !Rs2Player.isAnimating()); @@ -261,17 +261,17 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } } - if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof ConditionalStep) { - QuestStep conditionalStep = getQuestHelperPlugin().getSelectedQuest().getCurrentStep().getActiveStep(); + if (selectedQuest.getCurrentStep() instanceof ConditionalStep) { + QuestStep conditionalStep = selectedQuest.getCurrentStep().getActiveStep(); applyStep(conditionalStep); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof NpcStep) { - applyNpcStep((NpcStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof ObjectStep) { - applyObjectStep((ObjectStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof DigStep) { - applyDigStep((DigStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); - } else if (getQuestHelperPlugin().getSelectedQuest().getCurrentStep() instanceof PuzzleStep) { - applyPuzzleStep((PuzzleStep) getQuestHelperPlugin().getSelectedQuest().getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof NpcStep) { + applyNpcStep((NpcStep) selectedQuest.getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof ObjectStep) { + applyObjectStep((ObjectStep) selectedQuest.getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof DigStep) { + applyDigStep((DigStep) selectedQuest.getCurrentStep()); + } else if (selectedQuest.getCurrentStep() instanceof PuzzleStep) { + applyPuzzleStep((PuzzleStep) selectedQuest.getCurrentStep()); } sleepUntil(() -> Rs2Player.isInteracting() || Rs2Player.isMoving() || Rs2Player.isAnimating() || Rs2Dialogue.isInDialogue(), 500); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java index 37718a347f0..cea6e17aaa8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFiltering.java @@ -73,6 +73,7 @@ public static boolean passesSkillFilter(QuestHelper questHelper) public static boolean questPassesSkillFilter(QuestHelper questHelper) { + questHelper.initializeRequirements(); List skillsToFilterOut = Arrays.stream(Skill.values()) .filter(skill -> "true".equals(questHelper.getConfigManager().getConfiguration(QuestHelperConfig.QUEST_BACKGROUND_GROUP, "skillfilter" + skill.getName()))) .collect(Collectors.toList()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java index 1f8231026c3..d7e4016f082 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlay.java @@ -39,8 +39,11 @@ public Dimension render(Graphics2D graphics) { final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder == null) return null; + Shape minimapClipArea = plugin.getMinimapClipArea(); + if (minimapClipArea == null) return null; + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); - graphics.setClip(plugin.getMinimapClipArea()); + graphics.setClip(minimapClipArea); List pathPoints = pathfinder.getPath(); Color pathColor = pathfinder.isDone() ? plugin.colourPath : plugin.colourPathCalculating; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java index 79c5a60fb58..0f8c8c76fc7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java @@ -828,8 +828,8 @@ default boolean bankTripWhenCacheUnavailable() { } @ConfigSection( - name = "Spirit tree teleports", - description = "Toggle which spirit tree destinations to use", + name = "Farmed spirit trees (opt-in)", + description = "Enable only the farming-patch spirit trees you have grown yourself", position = 5, closedByDefault = true ) @@ -838,56 +838,56 @@ default boolean bankTripWhenCacheUnavailable() { @ConfigItem( keyName = "spiritTreeEtceteria", name = "Etceteria", - description = "Use the spirit tree teleport to Etceteria", + description = "Use the spirit tree in Etceteria. Only enable if you have grown a spirit tree in this patch", position = 0, section = sectionSpiritTrees ) default boolean spiritTreeEtceteria() { - return true; + return false; } @ConfigItem( keyName = "spiritTreeBrimhaven", name = "Brimhaven", - description = "Use the spirit tree teleport to Brimhaven", + description = "Use the spirit tree in Brimhaven. Only enable if you have grown a spirit tree in this patch", position = 1, section = sectionSpiritTrees ) default boolean spiritTreeBrimhaven() { - return true; + return false; } @ConfigItem( keyName = "spiritTreePortSarim", name = "Port Sarim", - description = "Use the spirit tree teleport to Port Sarim", + description = "Use the spirit tree in Port Sarim. Only enable if you have grown a spirit tree in this patch", position = 2, section = sectionSpiritTrees ) default boolean spiritTreePortSarim() { - return true; + return false; } @ConfigItem( keyName = "spiritTreeHosidius", name = "Hosidius", - description = "Use the spirit tree teleport to Hosidius", + description = "Use the spirit tree in Hosidius. Only enable if you have grown a spirit tree in this patch", position = 3, section = sectionSpiritTrees ) default boolean spiritTreeHosidius() { - return true; + return false; } @ConfigItem( keyName = "spiritTreeFarmingGuild", name = "Farming Guild", - description = "Use the spirit tree teleport to the Farming Guild", + description = "Use the spirit tree in the Farming Guild. Only enable if you have grown a spirit tree in this patch", position = 4, section = sectionSpiritTrees ) default boolean spiritTreeFarmingGuild() { - return true; + return false; } @ConfigSection( diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java index e40d9123ebe..7228884b95d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java @@ -283,6 +283,10 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig if (isMoa) moaVisited++; continue; } + if (transport.getDestination() != null && config.isLearnedBlockedTransport( + node.packedPosition, WorldPointUtil.packWorldPoint(transport.getDestination()))) { + continue; + } if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (config.isIgnoreTeleportAndItems()) { @@ -394,6 +398,9 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P if (visitedBackward.get(originPacked)) { continue; } + if (config.isLearnedBlockedTransport(originPacked, node.packedPosition)) { + continue; + } if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (config.isIgnoreTeleportAndItems()) { continue; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 4a426e3491d..a36662c6121 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -639,17 +639,11 @@ private void refreshTransports(WorldPoint target, boolean inCombat) { } } - refreshBoostedLevels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + int[] boostedLevels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + refreshBoostedLevels = boostedLevels; Map varplayerValues = new HashMap<>(); Microbot.getClientThread().runOnClientThreadOptional(() -> { - for (int i = 0; i < SKILLS.length; i++) { - refreshBoostedLevels[i] = client.getBoostedSkillLevel(SKILLS[i]); - } - refreshBoostedLevels[Transport.TOTAL_LEVEL_INDEX] = client.getTotalLevel(); - Player localPlayer = client.getLocalPlayer(); - refreshBoostedLevels[Transport.COMBAT_LEVEL_INDEX] = - localPlayer == null ? 0 : localPlayer.getCombatLevel(); - refreshBoostedLevels[Transport.QUEST_POINTS_INDEX] = client.getVarpValue(VarPlayer.QUEST_POINTS); + readRequirementLevels(client, boostedLevels); for (int id : varbitIds) { Microbot.getVarbitValue(id); } @@ -742,9 +736,9 @@ private void refreshTransports(WorldPoint target, boolean inCombat) { .sorted() .toArray(); int[] sortedSkillOrdinals = requiredSkillOrdinals.stream().mapToInt(Integer::intValue).sorted().toArray(); - int verificationHash = computeTransportRefreshVerificationHash(refreshBoostedLevels, sortedSkillOrdinals, + int verificationHash = computeTransportRefreshVerificationHash(boostedLevels, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); - int[] verificationComponents = computeTransportRefreshVerificationComponents(refreshBoostedLevels, + int[] verificationComponents = computeTransportRefreshVerificationComponents(boostedLevels, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); long verifyTime = System.currentTimeMillis() - verifyStart; long captureStart = System.currentTimeMillis(); @@ -854,6 +848,11 @@ public boolean isBlockedTransportStep(int originPacked, int destinationPacked) { return isBlockedTransportStep(originPacked, destinationPacked, blockedTransportEdgesPacked); } + public boolean isLearnedBlockedTransport(int originPacked, int destinationPacked) { + return !learnedBlockedEdgeKeys.isEmpty() + && learnedBlockedEdgeKeys.contains(transportEdgeKey(originPacked, destinationPacked)); + } + static boolean isBlockedTransportStep(int originPacked, int destinationPacked, Set blockedEdges) { if (blockedEdges == null || blockedEdges.isEmpty()) { return false; @@ -1479,6 +1478,16 @@ static boolean meetsRequiredLevels(int[] requiredLevels, int[] currentLevels) { return true; } + static void readRequirementLevels(Client client, int[] levels) { + for (int i = 0; i < SKILLS.length; i++) { + levels[i] = client.getBoostedSkillLevel(SKILLS[i]); + } + levels[Transport.TOTAL_LEVEL_INDEX] = client.getTotalLevel(); + Player localPlayer = client.getLocalPlayer(); + levels[Transport.COMBAT_LEVEL_INDEX] = localPlayer == null ? 0 : localPlayer.getCombatLevel(); + levels[Transport.QUEST_POINTS_INDEX] = client.getVarpValue(VarPlayer.QUEST_POINTS); + } + private int currentRequirementLevel(int index) { if (index >= 0 && index < SKILLS.length) { return client.getBoostedSkillLevel(SKILLS[index]); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java index 1fc93089c49..c9b9ca5b2ec 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScript.java @@ -73,7 +73,8 @@ public static Map> getRegistry() { private long loopCount; private long transitionCount; private List> transitions; - private boolean initialized; + private volatile boolean initialized; + private final Object lifecycleLock = new Object(); /** Bounded ring buffer of recent transitions for the trace log. */ private static final int MAX_TRACE_SIZE = 50; @@ -136,8 +137,8 @@ protected S onError(S state, Exception e) { * @return false if the base guard paused execution, true otherwise */ public final boolean step() { - if (!initialized) { - initialize(); + if (!initialized && !initialize()) { + return false; } // Check the base Script guard (heartbeat, blocking events, pause, etc.) @@ -213,13 +214,25 @@ public final boolean step() { return true; } - private void initialize() { - currentState = initialState(); - stateEnteredAt = Instant.now(); - transitions = Collections.unmodifiableList(defineTransitions()); - initialized = true; - REGISTRY.put(getScriptName(), this); + private boolean initialize() { + synchronized (lifecycleLock) { + if (Thread.currentThread().isInterrupted()) { + return false; + } + currentState = initialState(); + previousState = null; + lastTransitionReason = null; + lastTransitionAt = null; + loopCount = 0; + transitionCount = 0; + traceBuffer.clear(); + stateEnteredAt = Instant.now(); + transitions = Collections.unmodifiableList(defineTransitions()); + initialized = true; + REGISTRY.put(getScriptName(), this); + } log.info("[{}] State machine initialized in state {}", getScriptName(), currentState); + return true; } /** @@ -245,13 +258,18 @@ private void publishSnapshot(boolean allGuardsFalse) { } } - snapshot.set(new StateSnapshot<>( + StateSnapshot next = new StateSnapshot<>( currentState, previousState, lastTransitionReason, lastTransitionAt, stateEnteredAt, loopCount, transitionCount, pending, new ArrayList<>(traceBuffer) - )); + ); + synchronized (lifecycleLock) { + if (initialized) { + snapshot.set(next); + } + } } /** @@ -287,8 +305,12 @@ public void forceState(S newState, String reason) { @Override public void shutdown() { - REGISTRY.remove(getScriptName()); super.shutdown(); + synchronized (lifecycleLock) { + initialized = false; + REGISTRY.remove(getScriptName(), this); + snapshot.set(null); + } } private String getScriptName() { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java index 50ad781823f..8c6067cea31 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java @@ -12,6 +12,9 @@ import net.runelite.client.plugins.PluginInstantiationException; import net.runelite.client.plugins.PluginManager; import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.security.LoginManager; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import javax.inject.Inject; import javax.swing.*; @@ -31,6 +34,7 @@ public class TestRunnerPlugin extends Plugin { private static final String PROP_TEST_MODE = "microbot.test.mode"; private static final String PROP_TEST_SCRIPT = "microbot.test.script"; private static final String PROP_TEST_TIMEOUT = "microbot.test.timeout"; + private static final String PROP_TEST_WORLD = "microbot.test.world"; @Inject private PluginManager pluginManager; @@ -39,7 +43,7 @@ public class TestRunnerPlugin extends Plugin { private Client client; private ScheduledExecutorService executor; - private boolean testStarted = false; + private volatile boolean testStarted = false; @Override protected void startUp() { @@ -49,7 +53,12 @@ protected void startUp() { executor = Executors.newSingleThreadScheduledExecutor(); prepareTargetPlugin(); - enableAutoLogin(); + int world = getTestWorld(); + if (world > 0) { + scheduleWorldLogin(world); + } else { + enableAutoLogin(); + } long timeout = getTimeout(); executor.schedule(() -> { @@ -79,6 +88,53 @@ private void prepareTargetPlugin() { } } + private void scheduleWorldLogin(int world) { + log.info("[TestRunner] Logging in to world {} with the active profile", world); + executor.scheduleWithFixedDelay(() -> { + if (testStarted) { + return; + } + try { + if (client.getGameState() == GameState.LOGGED_IN) { + dismissWelcomeScreen(); + return; + } + if (client.getGameState() != GameState.LOGIN_SCREEN) { + return; + } + LoginManager.login(world); + } catch (Exception e) { + log.warn("[TestRunner] Login attempt to world {} failed: {}", world, e.getMessage()); + } + }, 5, 10, TimeUnit.SECONDS); + } + + private void dismissWelcomeScreen() { + boolean playVisible = Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget play = client.getWidget(InterfaceID.WelcomeScreen.PLAY); + return play != null && !play.isHidden(); + }).orElse(false); + if (playVisible) { + log.info("[TestRunner] Dismissing the welcome screen"); + Rs2Widget.clickWidget(InterfaceID.WelcomeScreen.PLAY); + } + } + + static int parseWorld(String value) { + if (value == null || value.isBlank()) { + return -1; + } + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static int getTestWorld() { + return parseWorld(System.getProperty(PROP_TEST_WORLD)); + } + private void enableAutoLogin() { for (Plugin plugin : pluginManager.getPlugins()) { if (plugin instanceof AutoLoginPlugin) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java new file mode 100644 index 00000000000..9b6998c4321 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckContext.java @@ -0,0 +1,70 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class CheckContext { + + @FunctionalInterface + public interface Cleanup { + void run() throws Exception; + } + + public static final class NamedCleanup { + final String name; + final Cleanup action; + + NamedCleanup(String name, Cleanup action) { + this.name = name; + this.action = action; + } + } + + private final String checkId; + private final Map details = Collections.synchronizedMap(new LinkedHashMap<>()); + private final List cleanups = Collections.synchronizedList(new ArrayList<>()); + + public CheckContext(String checkId) { + this.checkId = checkId; + } + + public String getCheckId() { + return checkId; + } + + public void detail(String key, Object value) { + details.put(key, value == null ? null : String.valueOf(value)); + } + + public void onCleanup(String name, Cleanup action) { + cleanups.add(new NamedCleanup(name, action)); + } + + public void require(boolean condition, String message) { + if (!condition) { + throw new CheckFailedException(message); + } + } + + public void skip(String reason) { + throw new CheckSkippedException(reason); + } + + Map snapshotDetails() { + synchronized (details) { + return new LinkedHashMap<>(details); + } + } + + List cleanupsInReverseOrder() { + List copy; + synchronized (cleanups) { + copy = new ArrayList<>(cleanups); + } + Collections.reverse(copy); + return copy; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java new file mode 100644 index 00000000000..2cb62b4971d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckFailedException.java @@ -0,0 +1,7 @@ +package net.runelite.client.plugins.microbot.testing.release; + +public class CheckFailedException extends RuntimeException { + public CheckFailedException(String message) { + super(message); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java new file mode 100644 index 00000000000..5af8d0fe08a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckOutcome.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class CheckOutcome { + public int order; + public String id; + public String description; + public ReleaseTier tier; + public CheckStatus status; + public long durationMs; + public long timeoutMs; + public String startedAt; + public String detail; + public Map details = new LinkedHashMap<>(); + public List cleanupErrors = new ArrayList<>(); + + public static CheckOutcome notRun(int order, ReleaseCheck check, String reason) { + CheckOutcome outcome = new CheckOutcome(); + outcome.order = order; + outcome.id = check.getId(); + outcome.description = check.getDescription(); + outcome.tier = check.getTier(); + outcome.timeoutMs = check.getTimeoutMs(); + outcome.status = CheckStatus.NOT_RUN; + outcome.detail = reason; + return outcome; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java new file mode 100644 index 00000000000..d11b1a76cbc --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckSkippedException.java @@ -0,0 +1,7 @@ +package net.runelite.client.plugins.microbot.testing.release; + +public class CheckSkippedException extends RuntimeException { + public CheckSkippedException(String reason) { + super(reason); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java new file mode 100644 index 00000000000..81e2f16674f --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/CheckStatus.java @@ -0,0 +1,12 @@ +package net.runelite.client.plugins.microbot.testing.release; + +public enum CheckStatus { + PASSED, + FAILED, + SKIPPED, + NOT_RUN; + + public boolean isGreen() { + return this == PASSED || this == SKIPPED; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java new file mode 100644 index 00000000000..9b85198f4e9 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScanner.java @@ -0,0 +1,102 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public final class ClientLogScanner { + + public static final class AllowRule { + private final Pattern pattern; + private final String reason; + + public AllowRule(String regex, String reason) { + this.pattern = Pattern.compile(regex); + this.reason = reason; + } + + public boolean matches(String line) { + return pattern.matcher(line).find(); + } + + public String getReason() { + return reason; + } + + public String getRegex() { + return pattern.pattern(); + } + } + + public static final List DEFAULT_ALLOWLIST = List.of( + new AllowRule("ERROR n\\.r\\.c\\.p\\.m\\.s\\.StateMachineExampleScript - \\[StateMachineExample\\] Error in state [A-Z_]+: Interrupted waiting for client thread", + "script-lifecycle stops the example script mid-scan; the script logs the interrupt it is sent at ERROR") + ); + + static final String STARTUP_MARKER = ") starting up, args:"; + + private static final Pattern ERROR_LINE = Pattern.compile( + "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} \\S+ \\[[^\\]]*\\] ERROR "); + + private ClientLogScanner() { + } + + public static Path defaultLogFile() { + return Paths.get(System.getProperty("user.home"), ".runelite", "logs", "client.log"); + } + + public static List readCurrentRun(Path logFile) throws IOException { + if (!Files.exists(logFile)) { + return List.of(); + } + return currentRun(Files.readAllLines(logFile, StandardCharsets.UTF_8)); + } + + public static List currentRun(List lines) { + for (int index = lines.size() - 1; index >= 0; index--) { + if (lines.get(index).contains(STARTUP_MARKER)) { + return new ArrayList<>(lines.subList(index, lines.size())); + } + } + return new ArrayList<>(lines); + } + + public static boolean isErrorLine(String line) { + return ERROR_LINE.matcher(line).find(); + } + + public static List findErrors(List lines, List allowlist) { + List errors = new ArrayList<>(); + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + if (!isErrorLine(line) || isAllowed(line, allowlist)) { + continue; + } + String next = index + 1 < lines.size() ? lines.get(index + 1) : ""; + if (!next.isEmpty() && !isLogLine(next)) { + errors.add(line + " | " + next.trim()); + } else { + errors.add(line); + } + } + return errors; + } + + static boolean isAllowed(String line, List allowlist) { + for (AllowRule rule : allowlist) { + if (rule.matches(line)) { + return true; + } + } + return false; + } + + private static boolean isLogLine(String line) { + return line.length() > 20 && Character.isDigit(line.charAt(0)) && line.charAt(4) == '-' && line.charAt(10) == ' '; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java new file mode 100644 index 00000000000..a4db8d6d87e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheck.java @@ -0,0 +1,69 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.Objects; + +public final class ReleaseCheck { + + @FunctionalInterface + public interface Body { + void run(CheckContext context) throws Exception; + } + + private final String id; + private final String description; + private final ReleaseTier tier; + private final long timeoutMs; + private final boolean alwaysRun; + private final Body body; + + private ReleaseCheck(String id, String description, ReleaseTier tier, long timeoutMs, boolean alwaysRun, Body body) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Check id is required"); + } + if (timeoutMs <= 0) { + throw new IllegalArgumentException("Check timeout must be positive for " + id); + } + this.id = id; + this.description = Objects.requireNonNull(description, "description"); + this.tier = Objects.requireNonNull(tier, "tier"); + this.timeoutMs = timeoutMs; + this.alwaysRun = alwaysRun; + this.body = Objects.requireNonNull(body, "body"); + } + + public static ReleaseCheck quick(String id, String description, long timeoutMs, Body body) { + return new ReleaseCheck(id, description, ReleaseTier.QUICK, timeoutMs, false, body); + } + + public static ReleaseCheck full(String id, String description, long timeoutMs, Body body) { + return new ReleaseCheck(id, description, ReleaseTier.FULL, timeoutMs, false, body); + } + + public ReleaseCheck alwaysRun() { + return new ReleaseCheck(id, description, tier, timeoutMs, true, body); + } + + public String getId() { + return id; + } + + public String getDescription() { + return description; + } + + public ReleaseTier getTier() { + return tier; + } + + public long getTimeoutMs() { + return timeoutMs; + } + + public boolean isAlwaysRun() { + return alwaysRun; + } + + public Body getBody() { + return body; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java new file mode 100644 index 00000000000..2f9b3c3473c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunner.java @@ -0,0 +1,275 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import java.util.stream.Collectors; + +public class ReleaseCheckRunner { + + public static final class Options { + private boolean failFast; + private long totalTimeoutMs = 600_000L; + private long cleanupTimeoutMs = 60_000L; + private long terminationWaitMs = 5_000L; + + public Options failFast(boolean failFast) { + this.failFast = failFast; + return this; + } + + public Options totalTimeoutMs(long totalTimeoutMs) { + this.totalTimeoutMs = totalTimeoutMs; + return this; + } + + public Options cleanupTimeoutMs(long cleanupTimeoutMs) { + this.cleanupTimeoutMs = cleanupTimeoutMs; + return this; + } + + public Options terminationWaitMs(long terminationWaitMs) { + this.terminationWaitMs = terminationWaitMs; + return this; + } + + public boolean isFailFast() { + return failFast; + } + + public long getTotalTimeoutMs() { + return totalTimeoutMs; + } + } + + private final Options options; + private final LongSupplier clock; + + public ReleaseCheckRunner(Options options) { + this(options, System::currentTimeMillis); + } + + ReleaseCheckRunner(Options options, LongSupplier clock) { + this.options = options; + this.clock = clock; + } + + public static List select(List all, ReleaseTier tier, Collection requestedIds) { + Set known = new HashSet<>(); + for (ReleaseCheck check : all) { + if (!known.add(normalize(check.getId()))) { + throw new IllegalArgumentException("Duplicate release E2E check id: " + check.getId()); + } + } + + Set requested = new LinkedHashSet<>(); + if (requestedIds != null) { + for (String id : requestedIds) { + if (id != null && !id.isBlank()) { + requested.add(normalize(id)); + } + } + } + + if (requested.isEmpty()) { + return all.stream() + .filter(check -> tier.includes(check.getTier())) + .collect(Collectors.toList()); + } + + List unknown = requested.stream() + .filter(id -> !known.contains(id)) + .collect(Collectors.toList()); + if (!unknown.isEmpty()) { + throw new IllegalArgumentException("Unknown release E2E check id(s) " + unknown + "; known ids: " + + all.stream().map(ReleaseCheck::getId).collect(Collectors.joining(", "))); + } + + return all.stream() + .filter(check -> requested.contains(normalize(check.getId()))) + .collect(Collectors.toList()); + } + + public List run(List checks, Consumer listener) { + List outcomes = new ArrayList<>(); + long deadline = clock.getAsLong() + options.totalTimeoutMs; + String stopReason = null; + + for (int index = 0; index < checks.size(); index++) { + ReleaseCheck check = checks.get(index); + int order = index + 1; + + if (stopReason == null && Thread.currentThread().isInterrupted()) { + stopReason = "harness interrupted"; + } + + long remaining = deadline - clock.getAsLong(); + if (stopReason == null && remaining <= 0) { + stopReason = "total timeout of " + options.totalTimeoutMs + "ms exceeded"; + } + + CheckOutcome outcome; + if (stopReason != null && !check.isAlwaysRun()) { + outcome = CheckOutcome.notRun(order, check, "Not run: " + stopReason); + } else { + long budget = check.isAlwaysRun() ? check.getTimeoutMs() : Math.min(check.getTimeoutMs(), remaining); + outcome = runOne(order, check, budget); + if (outcome.status == CheckStatus.FAILED && options.failFast && stopReason == null) { + stopReason = "fail-fast after " + check.getId() + " failed"; + } + } + + outcomes.add(outcome); + if (listener != null) { + listener.accept(outcome); + } + } + return outcomes; + } + + CheckOutcome runOne(int order, ReleaseCheck check, long budgetMs) { + CheckOutcome outcome = new CheckOutcome(); + outcome.order = order; + outcome.id = check.getId(); + outcome.description = check.getDescription(); + outcome.tier = check.getTier(); + outcome.timeoutMs = budgetMs; + outcome.startedAt = Instant.ofEpochMilli(clock.getAsLong()).toString(); + + CheckContext context = new CheckContext(check.getId()); + long start = clock.getAsLong(); + ExecutorService executor = Executors.newSingleThreadExecutor(daemonFactory("ReleaseE2E-" + check.getId())); + Future future = executor.submit(() -> { + check.getBody().run(context); + return null; + }); + + boolean harnessInterrupted = false; + try { + future.get(Math.max(1L, budgetMs), TimeUnit.MILLISECONDS); + outcome.status = CheckStatus.PASSED; + } catch (TimeoutException e) { + future.cancel(true); + outcome.status = CheckStatus.FAILED; + outcome.detail = budgetMs < check.getTimeoutMs() + ? "Timed out after " + budgetMs + "ms (remaining total budget; check timeout " + check.getTimeoutMs() + "ms)" + : "Timed out after " + budgetMs + "ms"; + } catch (ExecutionException e) { + classify(outcome, e.getCause()); + } catch (InterruptedException e) { + future.cancel(true); + harnessInterrupted = true; + outcome.status = CheckStatus.FAILED; + outcome.detail = "Harness interrupted while running check"; + } finally { + executor.shutdownNow(); + } + + boolean wasInterrupted = Thread.interrupted() || harnessInterrupted; + try { + if (!executor.awaitTermination(options.terminationWaitMs, TimeUnit.MILLISECONDS)) { + outcome.cleanupErrors.add("check thread still running after cancellation"); + } + } catch (InterruptedException e) { + wasInterrupted = true; + } + + runCleanups(context, outcome); + outcome.details = context.snapshotDetails(); + outcome.durationMs = clock.getAsLong() - start; + + if (!outcome.cleanupErrors.isEmpty()) { + String cleanupMessage = "Cleanup failed: " + String.join("; ", outcome.cleanupErrors); + if (outcome.status != CheckStatus.FAILED) { + outcome.status = CheckStatus.FAILED; + outcome.detail = outcome.detail == null ? cleanupMessage : outcome.detail + " | " + cleanupMessage; + } else { + outcome.detail = outcome.detail + " | " + cleanupMessage; + } + } + + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + return outcome; + } + + private void runCleanups(CheckContext context, CheckOutcome outcome) { + List cleanups = context.cleanupsInReverseOrder(); + if (cleanups.isEmpty()) { + return; + } + + ExecutorService executor = Executors.newSingleThreadExecutor(daemonFactory("ReleaseE2E-cleanup-" + context.getCheckId())); + try { + Future> future = executor.submit(() -> { + List errors = new ArrayList<>(); + for (CheckContext.NamedCleanup cleanup : cleanups) { + try { + cleanup.action.run(); + } catch (Throwable t) { + errors.add(cleanup.name + ": " + describe(t)); + } + } + return errors; + }); + outcome.cleanupErrors.addAll(future.get(options.cleanupTimeoutMs, TimeUnit.MILLISECONDS)); + } catch (TimeoutException e) { + outcome.cleanupErrors.add("cleanup timed out after " + options.cleanupTimeoutMs + "ms"); + } catch (ExecutionException e) { + outcome.cleanupErrors.add(describe(e.getCause())); + } catch (InterruptedException e) { + outcome.cleanupErrors.add("cleanup interrupted"); + Thread.currentThread().interrupt(); + } finally { + executor.shutdownNow(); + } + } + + private static void classify(CheckOutcome outcome, Throwable cause) { + if (cause instanceof CheckSkippedException) { + outcome.status = CheckStatus.SKIPPED; + outcome.detail = cause.getMessage(); + } else if (cause instanceof CheckFailedException) { + outcome.status = CheckStatus.FAILED; + outcome.detail = cause.getMessage(); + } else { + outcome.status = CheckStatus.FAILED; + outcome.detail = describe(cause); + } + } + + static String describe(Throwable t) { + if (t == null) { + return "unknown error"; + } + String message = t.getMessage(); + return t.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : ": " + message); + } + + private static String normalize(String id) { + return id.trim().toLowerCase(Locale.ROOT); + } + + private static ThreadFactory daemonFactory(String name) { + return runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java new file mode 100644 index 00000000000..e9e8857c172 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeChecks.java @@ -0,0 +1,958 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.runelite.api.GameState; +import net.runelite.api.Skill; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.RuneLiteProperties; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginPlugin; +import net.runelite.client.plugins.microbot.agentserver.AgentServerConfig; +import net.runelite.client.plugins.microbot.agentserver.AgentServerPlugin; +import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; +import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; +import net.runelite.client.plugins.microbot.api.tileitem.models.Rs2TileItemModel; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManifest; +import net.runelite.client.plugins.microbot.statemachine.StateMachineScript; +import net.runelite.client.plugins.microbot.statemachineexample.StateMachineExamplePlugin; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +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.walker.Rs2Walker; +import net.runelite.client.game.WorldService; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; +import net.runelite.http.api.worlds.World; +import net.runelite.http.api.worlds.WorldResult; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.distance; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.format; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.location; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.requireLoggedIn; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.waitFor; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.walkOrFail; +import static net.runelite.client.plugins.microbot.testing.release.ReleaseSmokeLive.walkToStart; + +public final class ReleaseSmokeChecks { + + static final WorldPoint LUMBRIDGE_HALL = new WorldPoint(3215, 3218, 0); + static final WorldPoint LUMBRIDGE_COURTYARD = new WorldPoint(3222, 3218, 0); + static final WorldPoint LUMBRIDGE_BANK = new WorldPoint(3208, 3220, 2); + static final WorldPoint VARROCK_WEST_BANK = new WorldPoint(3185, 3436, 0); + static final WorldPoint GRAND_EXCHANGE = new WorldPoint(3165, 3484, 0); + static final List DOOR_CANDIDATES = List.of( + new WorldPoint(3226, 3214, 0), + new WorldPoint(3226, 3223, 0), + new WorldPoint(3207, 3217, 0)); + static final List JUNK_ITEMS = List.of( + "Burnt meat", "Burnt fish", "Burnt bread", "Cowhide", "Egg", "Bones", "Ashes", "Bowl", "Pot", "Jug", "Feather"); + static final String DEFAULT_HUB_PLUGIN = "AttackRangesPlusPlugin"; + static final String DIALOGUE_OPTION = "What is this place"; + static final String SKILLS_TEXT = "Total level"; + static final int F2P_BANK_SLOTS = 400; + static final int SCRIPT_POOL_CORE_SIZE = 10; + + public static final class Settings { + public String expectedVersion = ""; + public long loginTimeoutMs = 240_000L; + public String hubPlugin = DEFAULT_HUB_PLUGIN; + public int hopWorld = -1; + } + + private final Settings settings; + private final PluginManager pluginManager; + private final ConfigManager configManager; + private final MicrobotPluginManager microbotPluginManager; + private final WorldService worldService; + + public ReleaseSmokeChecks(Settings settings, PluginManager pluginManager, ConfigManager configManager, + MicrobotPluginManager microbotPluginManager, WorldService worldService) { + this.settings = settings; + this.pluginManager = pluginManager; + this.configManager = configManager; + this.microbotPluginManager = microbotPluginManager; + this.worldService = worldService; + } + + public List all() { + List checks = new ArrayList<>(); + checks.add(ReleaseCheck.quick("boot", "Client version, core plugins, clean startup log", 30_000L, this::boot)); + checks.add(ReleaseCheck.quick("login", "Logged in with a known player location", 30_000L, this::login)); + checks.add(ReleaseCheck.quick("state-caches", "Skills, items, varbits/varps and entity queryables at Lumbridge castle", 360_000L, this::stateAndCaches)); + checks.add(ReleaseCheck.quick("widgets", "Tab switching by widget click and widget text search", 60_000L, this::widgets)); + checks.add(ReleaseCheck.quick("walker-transport", "Short walk, closed door and Lumbridge castle stairs", 300_000L, this::walkerTransport)); + checks.add(ReleaseCheck.quick("bank", "Open nearest bank, deposit and withdraw one junk item", 180_000L, this::bank)); + checks.add(ReleaseCheck.quick("npc-dialogue", "Talk to a banker, read and pick a dialogue option, exit", 120_000L, this::npcDialogue)); + checks.add(ReleaseCheck.quick("object-interaction", "Open and close a door, restoring its state", 180_000L, this::objectInteraction)); + checks.add(ReleaseCheck.quick("agent-server", "Agent Server state, inventory, widgets and skills endpoints", 60_000L, this::agentServer)); + checks.add(ReleaseCheck.full("walker-medium", "Lumbridge to Varrock west bank", 420_000L, this::walkerMedium)); + checks.add(ReleaseCheck.full("grand-exchange", "Open and close the Grand Exchange without offers", 300_000L, this::grandExchange)); + checks.add(ReleaseCheck.full("world-hop", "Hop to another F2P world and back", 180_000L, this::worldHop)); + checks.add(ReleaseCheck.full("hub-plugins", "Plugin Hub manifest, install/start/stop/uninstall a harmless plugin", 180_000L, this::hubPlugins)); + checks.add(ReleaseCheck.full("script-lifecycle", "Start and stop a built-in script twice without leaked threads", 90_000L, this::scriptLifecycle)); + checks.add(ReleaseCheck.quick("logout", "Clean logout and full-run client.log scan", 60_000L, this::logout).alwaysRun()); + return checks; + } + + void boot(CheckContext context) throws IOException { + String version = RuneLiteProperties.getMicrobotVersion(); + context.detail("microbotVersion", version); + context.detail("microbotCommit", RuneLiteProperties.getMicrobotCommit()); + context.detail("runeliteVersion", RuneLiteProperties.getVersion()); + context.require(version != null && !version.isBlank() && !version.contains("${") && !"0.0.0".equals(version), + "Microbot version is not stamped: '" + version + "'"); + if (settings.expectedVersion != null && !settings.expectedVersion.isBlank()) { + context.require(settings.expectedVersion.equals(version), + "Expected Microbot version " + settings.expectedVersion + " but client reports " + version); + } + + List inactive = new ArrayList<>(); + List active = new ArrayList<>(); + for (Plugin plugin : pluginManager.getPlugins()) { + PluginDescriptor descriptor = plugin.getClass().getAnnotation(PluginDescriptor.class); + if (descriptor == null || !descriptor.alwaysOn() || descriptor.isExternal()) { + continue; + } + if (pluginManager.isPluginActive(plugin)) { + active.add(plugin.getClass().getSimpleName()); + } else { + inactive.add(plugin.getClass().getSimpleName()); + } + } + context.detail("alwaysOnActive", active); + context.require(active.stream().anyMatch("MicrobotPlugin"::equals), "MicrobotPlugin is not active"); + context.require(inactive.isEmpty(), "Always-on plugins not active: " + inactive); + + List errors = ClientLogScanner.findErrors( + ClientLogScanner.readCurrentRun(ClientLogScanner.defaultLogFile()), ClientLogScanner.DEFAULT_ALLOWLIST); + context.detail("startupErrorLines", errors.size()); + context.require(errors.isEmpty(), "ERROR lines during startup: " + summarize(errors)); + } + + void login(CheckContext context) { + long uptime = ManagementFactory.getRuntimeMXBean().getUptime(); + context.detail("uptimeAtHarnessStartMs", uptime); + requireLoggedIn(context); + context.require(Microbot.getClient().getGameState() == GameState.LOGGED_IN, "Game state is " + Microbot.getClient().getGameState()); + context.require(uptime <= settings.loginTimeoutMs, "Login took longer than " + settings.loginTimeoutMs + "ms (uptime " + uptime + "ms)"); + WorldPoint location = location(); + context.require(location != null, "Player location is unknown"); + int world = Microbot.getClient().getWorld(); + context.detail("location", format(location)); + context.detail("world", world); + context.detail("membersWorld", Rs2Player.isInMemberWorld()); + context.require(world > 0, "World number is not known"); + } + + void stateAndCaches(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_HALL, 4, 300_000L); + + Map levels = new LinkedHashMap<>(); + int total = 0; + for (Skill skill : Skill.values()) { + int level = Rs2Player.getRealSkillLevel(skill); + levels.put(skill.getName(), level); + total += level; + context.require(level >= 1 && level <= 99, "Implausible " + skill.getName() + " level " + level); + } + context.require(Rs2Player.getRealSkillLevel(Skill.HITPOINTS) >= 10, "Hitpoints below 10"); + context.detail("skills", levels.size()); + context.detail("totalLevel", total); + + List inventory = Rs2Inventory.all(); + int rawInventory = Microbot.getClientThread().runOnClientThreadOptional(() -> { + net.runelite.api.ItemContainer container = Microbot.getClient().getItemContainer(net.runelite.api.InventoryID.INVENTORY); + if (container == null) { + return 0; + } + int count = 0; + for (net.runelite.api.Item item : container.getItems()) { + if (item != null && item.getId() > 0) { + count++; + } + } + return count; + }).orElse(-1); + context.detail("inventoryItems", inventory.size()); + context.require(inventory.size() == rawInventory, "Inventory cache has " + inventory.size() + " items, client container has " + rawInventory); + context.require(!inventory.isEmpty(), "Inventory is empty; the release account needs coins and a few junk items"); + context.detail("equipmentItems", Rs2Equipment.items().size()); + + int runVarp = Microbot.getVarbitPlayerValue(173); + int rawRunVarp = Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getVarpValue(173)).orElse(-1); + int stoneVarbit = Microbot.getVarbitValue(VarbitID.RESIZABLE_STONE_ARRANGEMENT); + int rawStoneVarbit = Microbot.getClientThread().runOnClientThreadOptional( + () -> Microbot.getClient().getVarbitValue(VarbitID.RESIZABLE_STONE_ARRANGEMENT)).orElse(-1); + context.detail("runVarp", runVarp + "/" + rawRunVarp); + context.detail("stoneArrangementVarbit", stoneVarbit + "/" + rawStoneVarbit); + context.require(runVarp == rawRunVarp, "Cached varp 173 " + runVarp + " differs from client " + rawRunVarp); + context.require(stoneVarbit == rawStoneVarbit, "Cached varbit differs from client: " + stoneVarbit + " vs " + rawStoneVarbit); + context.require(Rs2Player.isRunEnabled() == (rawRunVarp == 1), "Rs2Player.isRunEnabled disagrees with varp 173"); + + WorldPoint here = location(); + List npcs = Microbot.getRs2NpcCache().query().within(here, 20).toListOnClientThread(); + List objects = Microbot.getRs2TileObjectCache().query().within(here, 15).toListOnClientThread(); + List groundItems = Microbot.getRs2TileItemCache().query().within(here, 20).toListOnClientThread(); + List players = Microbot.getRs2PlayerCache().query().toListOnClientThread(); + List npcNames = Microbot.getClientThread().runOnClientThreadOptional(() -> npcs.stream() + .map(Rs2NpcModel::getName).filter(Objects::nonNull).distinct().sorted().collect(Collectors.toList())) + .orElse(List.of()); + List itemNames = Microbot.getClientThread().runOnClientThreadOptional(() -> groundItems.stream() + .map(Rs2TileItemModel::getName).filter(Objects::nonNull).distinct().sorted().collect(Collectors.toList())) + .orElse(List.of()); + context.detail("npcs", npcs.size() + " " + npcNames); + context.detail("objects", objects.size()); + context.detail("groundItems", groundItems.size() + " " + itemNames); + context.detail("otherPlayers", players.size()); + context.require(npcs.size() >= 3, "Expected at least 3 NPCs near Lumbridge castle hall, found " + npcs.size()); + context.require(objects.size() >= 20, "Expected at least 20 tile objects near Lumbridge castle hall, found " + objects.size()); + context.require(!groundItems.isEmpty(), "Expected the Lumbridge kitchen item spawns in the ground item cache"); + context.require(Rs2Player.getLocalPlayer() != null, "Local player model unavailable"); + Rs2NpcModel nearestNpc = Microbot.getRs2NpcCache().query().within(here, 20).nearestOnClientThread(); + context.require(nearestNpc != null, "nearestOnClientThread returned no NPC although the list was non-empty"); + } + + void widgets(CheckContext context) { + requireLoggedIn(context); + InterfaceTab original = Rs2Tab.getCurrentTab(); + context.detail("originalTab", original); + context.onCleanup("restore tab", () -> { + if (original != null && original != InterfaceTab.NOTHING_SELECTED && Rs2Tab.getCurrentTab() != original) { + Rs2Tab.switchTo(original); + } + }); + + int topLevel = Microbot.getClientThread().runOnClientThreadOptional(() -> Microbot.getClient().getTopLevelInterfaceId()).orElse(-1); + context.detail("topLevelInterface", topLevel); + clickTabStone(context, topLevel, "Skills", InterfaceTab.SKILLS); + + context.require(waitFor(() -> Rs2Widget.hasWidget(SKILLS_TEXT), 3000), "Rs2Widget.hasWidget('" + SKILLS_TEXT + "') returned false on the skills tab"); + Widget totalLevel = Rs2Widget.findWidget(SKILLS_TEXT, false); + context.require(totalLevel != null, "Widget text search for '" + SKILLS_TEXT + "' found nothing"); + String text = Microbot.getClientThread().runOnClientThreadOptional(totalLevel::getText).orElse(""); + context.detail("searchHit", (totalLevel.getId() >>> 16) + ":" + (totalLevel.getId() & 0xFFFF) + " '" + text.replace("
", " ") + "'"); + context.require(text.contains(SKILLS_TEXT), "Search hit does not carry the text '" + SKILLS_TEXT + "': '" + text + "'"); + + clickTabStone(context, topLevel, "Inventory", InterfaceTab.INVENTORY); + } + + private void clickTabStone(CheckContext context, int topLevel, String action, InterfaceTab expected) { + Optional stone = Microbot.getClientThread().runOnClientThreadOptional(() -> { + for (int child = 0; child < 200; child++) { + Widget widget = Microbot.getClient().getWidget(topLevel, child); + if (widget == null || widget.isHidden() || widget.getActions() == null) { + continue; + } + for (String candidate : widget.getActions()) { + if (action.equalsIgnoreCase(candidate)) { + return new int[]{topLevel, child}; + } + } + } + return null; + }); + context.require(stone.isPresent(), "No visible tab stone with action '" + action + "' under interface " + topLevel); + boolean clicked = Rs2Widget.clickWidget(stone.get()[0], stone.get()[1]); + boolean switched = waitFor(() -> Rs2Tab.getCurrentTab() == expected, 5000); + context.detail(action + "Stone", stone.get()[0] + ":" + stone.get()[1] + " clicked=" + clicked + " tab=" + Rs2Tab.getCurrentTab()); + context.require(switched, "Clicking the " + action + " tab stone did not switch to " + expected + " (now " + Rs2Tab.getCurrentTab() + ")"); + } + + void walkerTransport(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_HALL, 6, 240_000L); + List failures = new ArrayList<>(); + step(failures, () -> walkOrFail(context, "shortWalk", LUMBRIDGE_COURTYARD, 1, 60_000L)); + step(failures, () -> walkThroughDoor(context)); + step(failures, () -> { + ReleaseSmokeLive.WalkResult stairs = walkOrFail(context, "stairsWalk", LUMBRIDGE_BANK, 3, 120_000L); + context.require(stairs.end.getPlane() == 2, "Stairs walk ended on plane " + stairs.end.getPlane()); + }); + context.require(failures.isEmpty(), String.join(" | ", failures)); + } + + private static void step(List failures, Runnable step) { + try { + step.run(); + } catch (CheckFailedException e) { + failures.add(e.getMessage()); + } + } + + private void walkThroughDoor(CheckContext context) { + List attempts = new ArrayList<>(); + for (WorldPoint anchor : DOOR_CANDIDATES) { + ReleaseSmokeLive.WalkResult approach = ReleaseSmokeLive.walk(anchor, 4, 90_000L); + if (!approach.arrived(4)) { + attempts.add(format(anchor) + ": approach " + approach.describe()); + continue; + } + Optional door = ReleaseSmokeLive.doorAt(anchor, 1); + if (door.isEmpty()) { + attempts.add(format(anchor) + ": no door found"); + continue; + } + boolean initiallyClosed = door.get().isClosed(); + context.detail("door", door.get().describe()); + if (!ReleaseSmokeLive.setDoorState(context, "doorPrepare", anchor, true, 6000)) { + attempts.add(format(anchor) + ": could not close door before walking"); + continue; + } + AtomicBoolean restored = new AtomicBoolean(false); + context.onCleanup("restore door " + format(anchor), () -> { + if (restored.get()) { + return; + } + if (distance(location(), anchor) > 6) { + ReleaseSmokeLive.walk(anchor, 3, 90_000L); + } + if (!ReleaseSmokeLive.setDoorState(context, "doorRestore", anchor, initiallyClosed, 6000)) { + throw new IllegalStateException("door at " + format(anchor) + " not restored to " + (initiallyClosed ? "closed" : "open")); + } + }); + ReleaseSmokeLive.Door closed = ReleaseSmokeLive.doorAt(anchor, 1).orElseThrow(); + Optional orientation = ReleaseSmokeLive.wallOrientation(closed.location); + WorldPoint across = orientation.map(value -> ReleaseSmokeLive.acrossWall(closed.location, value)).orElse(null); + context.require(across != null, "Cannot read wall orientation for " + closed.describe()); + boolean doorTileReachable = ReleaseSmokeLive.reachable(closed.location); + WorldPoint target = doorTileReachable ? across : closed.location; + context.detail("doorWalkTarget", format(target) + " (orientation " + orientation.get() + ", doorTileReachable=" + doorTileReachable + ")"); + ReleaseSmokeLive.WalkResult through = ReleaseSmokeLive.walk(target, 0, 90_000L); + context.detail("doorWalk", through.describe()); + context.detail("doorAfterWalk", ReleaseSmokeLive.doorAt(anchor, 1).map(ReleaseSmokeLive.Door::describe).orElse("none")); + restored.set(ReleaseSmokeLive.setDoorState(context, "doorRestore", anchor, initiallyClosed, 6000)); + context.require(through.arrived(0), "Walk through closed door to " + format(target) + " failed: " + through.describe()); + return; + } + throw new CheckFailedException("No usable door near Lumbridge castle: " + attempts); + } + + void bank(CheckContext context) { + requireLoggedIn(context); + BankLocation nearest = Rs2Bank.getNearestBank(LUMBRIDGE_BANK); + context.detail("nearestBank", nearest == null ? "null" : nearest.name()); + context.require(nearest != null, "Rs2Bank.getNearestBank returned null at Lumbridge"); + if (distance(location(), nearest.getWorldPoint()) > 6) { + walkOrFail(context, "bankWalk", nearest.getWorldPoint(), 4, 150_000L); + } + + Map before = inventorySnapshot(); + context.onCleanup("close bank", () -> { + if (Rs2Bank.isOpen()) { + Rs2Bank.closeBank(); + if (!waitFor(() -> !Rs2Bank.isOpen(), 5000)) { + throw new IllegalStateException("bank still open"); + } + } + }); + + boolean opened = Rs2Bank.openBank() && waitFor(Rs2Bank::isOpen, 10_000); + if (!opened && Rs2Bank.isBankPinWidgetVisible()) { + context.skip("Bank PIN prompt shown and no PIN configured for the profile"); + } + context.require(opened || Rs2Bank.isOpen(), "Bank did not open"); + waitFor(() -> !Rs2Bank.bankItems().isEmpty(), 5000); + int bankSize = Rs2Bank.bankItems() == null ? 0 : Rs2Bank.bankItems().size(); + context.detail("bankItemStacks", bankSize); + + Rs2ItemModel junk = pickJunk(); + if (junk == null) { + context.skip("No junk item in inventory (expected one of " + JUNK_ITEMS + ")"); + } + int itemId = junk.getId(); + boolean alreadyBanked = Rs2Bank.count(itemId) > 0; + context.detail("junkItem", junk.getName() + "#" + itemId + (alreadyBanked ? " (stack exists in bank)" : " (new bank slot)")); + context.onCleanup("withdraw junk back", () -> { + int missing = before.getOrDefault(itemId, 0) - Rs2Inventory.itemQuantity(itemId); + if (missing > 0 && Rs2Bank.isOpen()) { + if (missing == 1) { + Rs2Bank.withdrawOne(itemId); + } else { + Rs2Bank.withdrawX(itemId, missing); + } + if (!waitFor(() -> Rs2Inventory.itemQuantity(itemId) >= before.getOrDefault(itemId, 0), 5000)) { + throw new IllegalStateException("junk item not withdrawn back"); + } + } + }); + + boolean noted = Rs2Bank.hasWithdrawAsNote(); + context.detail("withdrawAsNote", noted); + if (noted) { + context.require(Rs2Bank.setWithdrawAsItem(), "Could not switch withdraw mode to items"); + context.onCleanup("restore withdraw-as-note", () -> { + if (Rs2Bank.isOpen() && !Rs2Bank.hasWithdrawAsNote()) { + Rs2Bank.setWithdrawAsNote(); + } + }); + } + + int invBefore = Rs2Inventory.itemQuantity(itemId); + int bankBefore = Rs2Bank.count(itemId); + context.detail("countsBefore", "inventory=" + invBefore + ", bank=" + bankBefore); + context.require(Rs2Bank.depositOne(itemId), "depositOne returned false"); + boolean deposited = waitFor(() -> Rs2Inventory.itemQuantity(itemId) == invBefore - 1, 6000); + if (!deposited && !alreadyBanked && !Rs2Player.isMember() && bankSize >= F2P_BANK_SLOTS) { + context.skip("Bank holds " + bankSize + " stacks (F2P limit " + F2P_BANK_SLOTS + ") and no junk item already has a bank stack"); + } + context.require(deposited, "Inventory count did not drop after depositOne (" + Rs2Inventory.itemQuantity(itemId) + ")"); + context.require(waitFor(() -> Rs2Bank.count(itemId) == bankBefore + 1, 6000), + "Bank count did not rise after deposit (" + bankBefore + " -> " + Rs2Bank.count(itemId) + ")"); + + context.require(Rs2Bank.withdrawOne(itemId), "withdrawOne returned false"); + context.require(waitFor(() -> Rs2Inventory.itemQuantity(itemId) == invBefore, 6000), + "Inventory count not restored after withdraw (" + Rs2Inventory.itemQuantity(itemId) + " vs " + invBefore + ")"); + Rs2Bank.closeBank(); + context.require(waitFor(() -> !Rs2Bank.isOpen(), 5000), "Bank still open after closeBank"); + + Map after = inventorySnapshot(); + context.require(before.equals(after), "Inventory not restored: before=" + before + " after=" + after); + } + + private static Map inventorySnapshot() { + Map snapshot = new HashMap<>(); + for (Rs2ItemModel item : Rs2Inventory.all()) { + snapshot.merge(item.getId(), item.getQuantity(), Integer::sum); + } + return snapshot; + } + + private static Rs2ItemModel pickJunk() { + List candidates = new ArrayList<>(); + List items = Rs2Inventory.all(); + for (String name : JUNK_ITEMS) { + for (Rs2ItemModel item : items) { + if (name.equalsIgnoreCase(item.getName()) && !item.isNoted()) { + candidates.add(item); + } + } + } + return candidates.stream() + .filter(item -> Rs2Bank.count(item.getId()) > 0) + .findFirst() + .orElse(candidates.isEmpty() ? null : candidates.get(0)); + } + + void npcDialogue(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_BANK, 4, 180_000L); + context.onCleanup("leave dialogue", () -> { + if (Rs2Dialogue.isInDialogue()) { + leaveDialogue(); + if (!waitFor(() -> !Rs2Dialogue.isInDialogue(), 6000)) { + throw new IllegalStateException("still in dialogue"); + } + } + }); + + net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel banker = Rs2Npc.getNpc("Banker", true); + context.require(banker != null, "No banker found near Lumbridge bank"); + context.require(Rs2Npc.interact(banker, "Talk-to"), "Talk-to banker interaction failed"); + context.require(waitFor(Rs2Dialogue::isInDialogue, 10_000), "Dialogue did not open after Talk-to"); + + List transcript = new ArrayList<>(); + boolean pickedOption = false; + for (int step = 0; step < 8 && Rs2Dialogue.isInDialogue(); step++) { + if (Rs2Dialogue.hasSelectAnOption()) { + List options = dialogueOptions(); + transcript.add("options=" + options); + if (pickedOption) { + break; + } + boolean hasOption = options.stream().anyMatch(option -> option.contains(DIALOGUE_OPTION)); + context.require(hasOption, "Expected banker option '" + DIALOGUE_OPTION + "' in " + options); + context.require(Rs2Dialogue.keyPressForDialogueOption(DIALOGUE_OPTION, false), "Could not select dialogue option"); + pickedOption = true; + waitFor(() -> !Rs2Dialogue.hasSelectAnOption(), 3000); + continue; + } + if (Rs2Dialogue.hasContinue()) { + String text = Rs2Dialogue.getDialogueText(); + transcript.add("text=" + (text == null ? "" : text.length() > 60 ? text.substring(0, 60) : text)); + Rs2Dialogue.clickContinue(); + waitFor(() -> !Objects.equals(text, Rs2Dialogue.getDialogueText()) || !Rs2Dialogue.isInDialogue(), 4000); + continue; + } + waitFor(() -> Rs2Dialogue.hasContinue() || Rs2Dialogue.hasSelectAnOption() || !Rs2Dialogue.isInDialogue(), 3000); + } + context.detail("transcript", transcript); + context.require(pickedOption, "Never reached the banker option menu: " + transcript); + + if (Rs2Dialogue.isInDialogue()) { + leaveDialogue(); + } + context.require(waitFor(() -> !Rs2Dialogue.isInDialogue(), 6000), "Dialogue did not close"); + context.require(!Rs2Bank.isOpen(), "Bank opened during the banker dialogue"); + } + + private static List dialogueOptions() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> Rs2Dialogue.getDialogueOptions().stream() + .map(Widget::getText).filter(Objects::nonNull).collect(Collectors.toList())).orElse(List.of()); + } + + private static void leaveDialogue() { + WorldPoint here = location(); + if (here == null) { + return; + } + for (WorldPoint step : List.of(here.dx(1), here.dx(-1), here.dy(1), here.dy(-1))) { + if (ReleaseSmokeLive.reachable(step)) { + Rs2Walker.walkFastCanvas(step); + return; + } + } + } + + void objectInteraction(CheckContext context) { + requireLoggedIn(context); + List attempts = new ArrayList<>(); + for (WorldPoint anchor : DOOR_CANDIDATES) { + ReleaseSmokeLive.WalkResult approach = ReleaseSmokeLive.walk(anchor, 3, 150_000L); + if (!approach.arrived(3)) { + attempts.add(format(anchor) + ": approach " + approach.describe()); + continue; + } + Optional door = ReleaseSmokeLive.doorAt(anchor, 1); + if (door.isEmpty()) { + attempts.add(format(anchor) + ": no door"); + continue; + } + boolean initiallyClosed = door.get().isClosed(); + context.detail("door", door.get().describe()); + context.onCleanup("restore door " + format(anchor), () -> { + if (!ReleaseSmokeLive.setDoorState(context, "restore", anchor, initiallyClosed, 6000)) { + throw new IllegalStateException("door at " + format(anchor) + " not restored"); + } + }); + boolean first = ReleaseSmokeLive.setDoorState(context, "toggle1", anchor, !initiallyClosed, 6000); + if (!first) { + attempts.add(format(anchor) + ": first toggle had no effect"); + continue; + } + boolean second = ReleaseSmokeLive.setDoorState(context, "toggle2", anchor, initiallyClosed, 6000); + context.require(second, "Door at " + format(anchor) + " did not return to its original state"); + return; + } + throw new CheckFailedException("No door could be toggled: " + attempts); + } + + void agentServer(CheckContext context) throws Exception { + requireLoggedIn(context); + String bindMode = configManager.getConfiguration(AgentServerConfig.GROUP, "bindMode"); + if ("UDS".equalsIgnoreCase(bindMode)) { + context.skip("Agent Server is configured for UDS; this check speaks TCP only"); + } + AgentServerPlugin plugin = Microbot.getPlugin(AgentServerPlugin.class); + context.require(plugin != null, "AgentServerPlugin not registered"); + if (!pluginManager.isPluginActive(plugin)) { + context.detail("startedForCheck", true); + Microbot.startPlugin(plugin); + context.require(waitFor(() -> pluginManager.isPluginActive(plugin), 10_000), "Agent Server plugin did not start"); + context.onCleanup("stop agent server", () -> { + Microbot.stopPlugin(plugin); + if (!waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000)) { + throw new IllegalStateException("agent server still active"); + } + }); + } + String portValue = configManager.getConfiguration(AgentServerConfig.GROUP, AgentServerConfig.KEY_PORT); + int port = portValue == null || portValue.isBlank() ? 8081 : Integer.parseInt(portValue.trim()); + String token = configManager.getConfiguration(AgentServerConfig.GROUP, AgentServerConfig.KEY_TOKEN); + context.require(token != null && !token.isBlank(), "Agent Server token not configured"); + context.detail("port", port); + + int unauthenticatedStatus = status("http://127.0.0.1:" + port + "/state", null); + context.detail("unauthenticatedStatus", unauthenticatedStatus); + context.require(unauthenticatedStatus == 404, + "Agent Server should answer an unauthenticated request with the opaque 404, got HTTP " + unauthenticatedStatus); + + JsonObject state = getJson(port, token, "/state").getAsJsonObject(); + context.require(state.has("loggedIn") && state.get("loggedIn").getAsBoolean(), "/state does not report loggedIn: " + state); + JsonObject position = state.getAsJsonObject("player").getAsJsonObject("position"); + WorldPoint reported = new WorldPoint(position.get("x").getAsInt(), position.get("y").getAsInt(), position.get("plane").getAsInt()); + context.detail("statePosition", format(reported)); + context.require(distance(reported, location()) <= 5, "/state position " + format(reported) + " differs from " + format(location())); + + JsonObject inventory = getJson(port, token, "/inventory").getAsJsonObject(); + int reportedCount = inventory.get("count").getAsInt(); + context.detail("inventoryCount", reportedCount); + context.require(reportedCount == Rs2Inventory.count(), "/inventory count " + reportedCount + " != " + Rs2Inventory.count()); + + JsonObject describe = getJson(port, token, "/widgets/describe?groupId=" + net.runelite.api.gameval.InterfaceID.INVENTORY + "&childId=0&depth=2").getAsJsonObject(); + context.detail("widgetsDescribed", describe.get("count").getAsInt()); + context.require(describe.get("count").getAsInt() > 0, "/widgets/describe returned no widgets for the inventory"); + + JsonElement skills = getJson(port, token, "/skills"); + int skillCount = countSkills(skills); + context.detail("skills", skillCount); + context.require(skillCount >= 23, "/skills returned " + skillCount + " skills"); + } + + private static int countSkills(JsonElement skills) { + if (skills.isJsonArray()) { + return skills.getAsJsonArray().size(); + } + JsonObject object = skills.getAsJsonObject(); + if (object.has("skills")) { + JsonElement inner = object.get("skills"); + return inner.isJsonArray() ? inner.getAsJsonArray().size() : inner.getAsJsonObject().size(); + } + return object.size(); + } + + private static int status(String url, String token) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(15000); + if (token != null) { + connection.setRequestProperty("X-Agent-Token", token); + } + try { + return connection.getResponseCode(); + } finally { + connection.disconnect(); + } + } + + private static JsonElement getJson(int port, String token, String path) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL("http://127.0.0.1:" + port + path).openConnection(); + connection.setConnectTimeout(5000); + connection.setReadTimeout(30000); + connection.setRequestProperty("X-Agent-Token", token); + try { + int code = connection.getResponseCode(); + InputStream stream = code < 400 ? connection.getInputStream() : connection.getErrorStream(); + String body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + if (code != 200) { + throw new CheckFailedException(path + " returned HTTP " + code + ": " + truncate(body, 200)); + } + return new JsonParser().parse(body); + } finally { + connection.disconnect(); + } + } + + void walkerMedium(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "setupWalk", LUMBRIDGE_COURTYARD, 6, 300_000L); + ReleaseSmokeLive.WalkResult walk = walkOrFail(context, "mediumWalk", VARROCK_WEST_BANK, 5, 360_000L); + context.detail("maxStepJump", walk.maxJump + " " + walk.maxJumpEdge); + context.detail("teleportObserved", walk.maxJump > 30); + } + + void grandExchange(CheckContext context) { + requireLoggedIn(context); + walkToStart(context, "geWalk", GRAND_EXCHANGE, 6, 240_000L); + context.onCleanup("close grand exchange", () -> { + if (Rs2GrandExchange.isOpen()) { + Rs2GrandExchange.closeExchange(); + if (!waitFor(() -> !Rs2GrandExchange.isOpen(), 5000)) { + throw new IllegalStateException("grand exchange still open"); + } + } + if (Rs2Dialogue.isInDialogue()) { + leaveDialogue(); + } + }); + boolean opened = Rs2GrandExchange.openExchange(); + if (!opened && Rs2Dialogue.isInDialogue()) { + String text = Rs2Dialogue.getDialogueText(); + context.detail("clerkDialogue", text); + context.skip("Grand Exchange clerk refused to open the exchange: " + text); + } + context.require(opened && Rs2GrandExchange.isOpen(), "Grand Exchange interface did not open"); + Rs2GrandExchange.closeExchange(); + context.require(waitFor(() -> !Rs2GrandExchange.isOpen(), 5000), "Grand Exchange did not close"); + } + + void worldHop(CheckContext context) { + requireLoggedIn(context); + int original = Microbot.getClient().getWorld(); + context.detail("originalWorld", original); + WorldResult worlds = worldService.getWorlds(); + context.require(worlds != null && !worlds.getWorlds().isEmpty(), "World list service returned no worlds"); + int target = settings.hopWorld > 0 ? settings.hopWorld : pickF2pWorld(worlds.getWorlds(), original); + context.require(target > 0, "No other F2P world available to hop to"); + context.detail("targetWorld", target); + context.onCleanup("return to original world", () -> { + if (Microbot.isLoggedIn() && Microbot.getClient().getWorld() != original) { + if (!Microbot.hopToWorld(original)) { + throw new IllegalStateException("could not hop back to world " + original); + } + } + }); + + boolean switcherOpenBefore = worldSwitcherOpen(); + context.detail("worldSwitcherOpenBefore", switcherOpenBefore); + long start = System.currentTimeMillis(); + boolean hopped = Microbot.hopToWorld(target); + context.detail("hopOutMs", System.currentTimeMillis() - start); + if (!hopped || Microbot.getClient().getWorld() != target) { + boolean switcherOpenAfter = worldSwitcherOpen(); + boolean retried = Microbot.hopToWorld(target) && Microbot.getClient().getWorld() == target; + context.detail("worldSwitcherOpenAfterFirstAttempt", switcherOpenAfter); + context.detail("secondAttemptHopped", retried); + throw new CheckFailedException("Microbot.hopToWorld(" + target + ") did not hop (world switcher open before: " + + switcherOpenBefore + ", after: " + switcherOpenAfter + ", second attempt hopped: " + retried + ")"); + } + context.require(waitFor(() -> Microbot.getClient().getGameState() == GameState.LOGGED_IN && location() != null, 15_000), + "Not logged in with a location after hopping"); + + start = System.currentTimeMillis(); + boolean back = Microbot.hopToWorld(original); + context.detail("hopBackMs", System.currentTimeMillis() - start); + context.require(back && Microbot.getClient().getWorld() == original, "Hop back to world " + original + " failed"); + } + + private static boolean worldSwitcherOpen() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget buttons = Microbot.getClient().getWidget(net.runelite.api.gameval.InterfaceID.Worldswitcher.BUTTONS); + return buttons != null && !buttons.isHidden(); + }).orElse(false); + } + + static int pickF2pWorld(List worlds, int current) { + int currentLocation = worlds.stream() + .filter(world -> world.getId() == current) + .map(World::getLocation) + .findFirst() + .orElse(-1); + return worlds.stream() + .filter(world -> world.getId() != current) + .filter(world -> world.getTypes() == null || world.getTypes().isEmpty()) + .filter(world -> "-".equals(world.getActivity())) + .filter(world -> world.getPlayers() > 0 && world.getPlayers() < 1500) + .sorted(Comparator.comparing((World world) -> world.getLocation() != currentLocation) + .thenComparingInt(World::getPlayers)) + .map(World::getId) + .findFirst() + .orElse(-1); + } + + void hubPlugins(CheckContext context) { + boolean loaded = waitFor(() -> !microbotPluginManager.getManifestMap().isEmpty(), 60_000); + Map manifests = microbotPluginManager.getManifestMap(); + context.detail("manifestEntries", manifests.size()); + context.require(loaded && manifests.size() >= 20, "Plugin Hub manifest not loaded (" + manifests.size() + " entries)"); + long withJarInfo = manifests.values().stream() + .filter(manifest -> manifest.getInternalName() != null && manifest.getVersion() != null) + .count(); + context.require(withJarInfo == manifests.size(), "Some manifest entries are missing internalName/version"); + + String internalName = settings.hubPlugin; + MicrobotPluginManifest manifest = manifests.get(internalName); + context.require(manifest != null, "Hub plugin " + internalName + " not in manifest"); + context.detail("hubPlugin", internalName + " " + manifest.getVersion()); + if (manifest.isDisable()) { + context.skip("Hub plugin " + internalName + " is disabled upstream"); + } + + Optional existing = findExternal(internalName); + if (existing.isPresent()) { + context.detail("preinstalled", true); + boolean wasActive = pluginManager.isPluginActive(existing.get()); + cyclePlugin(context, existing.get()); + if (wasActive) { + Microbot.startPlugin(existing.get()); + context.require(waitFor(() -> pluginManager.isPluginActive(existing.get()), 10_000), "Could not restore active state"); + } + return; + } + + context.onCleanup("uninstall " + internalName, () -> { + if (findExternal(internalName).isPresent()) { + microbotPluginManager.remove(manifest); + if (findExternal(internalName).isPresent()) { + throw new IllegalStateException(internalName + " still registered after remove"); + } + } + }); + long start = System.currentTimeMillis(); + microbotPluginManager.install(manifest, null); + boolean installed = waitFor(() -> findExternal(internalName).isPresent(), 30_000); + context.detail("installMs", System.currentTimeMillis() - start); + context.require(installed, "Hub plugin " + internalName + " did not load after install"); + Plugin plugin = findExternal(internalName).orElseThrow(); + context.detail("activeAfterInstall", pluginManager.isPluginActive(plugin)); + cyclePlugin(context, plugin); + + microbotPluginManager.remove(manifest); + context.require(findExternal(internalName).isEmpty(), "Hub plugin still registered after uninstall"); + context.require(microbotPluginManager.getInstalledPluginVersion(internalName).isEmpty(), + "Installed version still recorded after uninstall"); + } + + private void cyclePlugin(CheckContext context, Plugin plugin) { + boolean startReturned = Microbot.startPlugin(plugin); + boolean started = waitFor(() -> pluginManager.isPluginActive(plugin), 10_000); + context.detail("startPluginReturned", startReturned); + context.require(started, plugin.getClass().getSimpleName() + " did not start"); + boolean stopReturned = Microbot.stopPlugin(plugin); + boolean stopped = waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000); + context.detail("stopPluginReturned", stopReturned); + context.require(stopped, plugin.getClass().getSimpleName() + " did not stop"); + } + + private Optional findExternal(String internalName) { + return pluginManager.getPlugins().stream() + .filter(plugin -> plugin.getClass().getSimpleName().equalsIgnoreCase(internalName)) + .filter(plugin -> { + PluginDescriptor descriptor = plugin.getClass().getAnnotation(PluginDescriptor.class); + return descriptor != null && descriptor.isExternal(); + }) + .findFirst(); + } + + void scriptLifecycle(CheckContext context) { + StateMachineExamplePlugin plugin = Microbot.getPlugin(StateMachineExamplePlugin.class); + context.require(plugin != null, "StateMachineExamplePlugin not registered"); + if (pluginManager.isPluginActive(plugin)) { + context.skip("State Machine Example is already running; not interfering with a user session"); + } + context.onCleanup("stop state machine example", () -> { + if (pluginManager.isPluginActive(plugin)) { + Microbot.stopPlugin(plugin); + if (!waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000)) { + throw new IllegalStateException("example plugin still active"); + } + } + }); + + List poolSizes = new ArrayList<>(); + List registry = new ArrayList<>(); + List startStates = new ArrayList<>(); + for (int cycle = 1; cycle <= 2; cycle++) { + int current = cycle; + Microbot.startPlugin(plugin); + context.require(waitFor(() -> pluginManager.isPluginActive(plugin), 10_000), "Cycle " + cycle + ": plugin did not start"); + context.require(waitFor(() -> exampleScript().isPresent(), 5000), "Cycle " + cycle + ": script not among Microbot.getActiveScripts()"); + StateMachineScript script = exampleScript().orElseThrow(); + long loopsAtStart = script.getSnapshot() == null ? 0 : script.getSnapshot().loopCount(); + startStates.add(script.getCurrentState() == null ? "null" : script.getCurrentState().name()); + boolean looping = waitFor(() -> script.isRunning() && script.getSnapshot() != null + && script.getSnapshot().loopCount() >= loopsAtStart + 3, 15_000); + context.require(looping, "Cycle " + cycle + ": script loop did not run (" + describeExample() + ")"); + registry.add(StateMachineScript.getRegistry().containsValue(script) ? "registered" : "missing"); + Microbot.stopPlugin(plugin); + context.require(waitFor(() -> !pluginManager.isPluginActive(plugin), 10_000), "Cycle " + cycle + ": plugin did not stop"); + context.require(waitFor(() -> !script.isRunning(), 5000), "Cycle " + current + ": script loop still running after stop"); + context.require(waitFor(() -> busyScriptThreads().isEmpty(), 5000), + "Cycle " + cycle + ": script threads still executing after stop: " + busyScriptThreads()); + poolSizes.add(scriptThreads().size()); + } + context.detail("scriptPoolThreadsAfterStop", poolSizes); + context.detail("stateMachineRegistry", registry); + context.detail("stateAtStart", startStates); + context.require(poolSizes.stream().allMatch(size -> size <= SCRIPT_POOL_CORE_SIZE), + "Script pool threads exceed the Script executor core size " + SCRIPT_POOL_CORE_SIZE + ": " + poolSizes); + context.require(!registry.contains("missing"), + "StateMachineScript was not in StateMachineScript.getRegistry() while running after a restart: " + registry); + } + + private static Optional> exampleScript() { + return Microbot.getActiveScripts().stream() + .filter(script -> script instanceof StateMachineScript) + .filter(script -> script.getClass().getSimpleName().equals("StateMachineExampleScript")) + .>map(script -> (StateMachineScript) script) + .findFirst(); + } + + private static String describeExample() { + return exampleScript().map(script -> "running=" + script.isRunning() + ", state=" + script.getCurrentState() + + ", loops=" + (script.getSnapshot() == null ? "?" : script.getSnapshot().loopCount())).orElse("not registered"); + } + + private static List scriptThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.isAlive() && thread.getName().startsWith("StateMachineExampleScript-")) + .collect(Collectors.toList()); + } + + private static List busyScriptThreads() { + List busy = new ArrayList<>(); + for (Map.Entry entry : Thread.getAllStackTraces().entrySet()) { + if (!entry.getKey().isAlive()) { + continue; + } + for (StackTraceElement frame : entry.getValue()) { + if (frame.getClassName().contains(".statemachineexample.")) { + busy.add(entry.getKey().getName()); + break; + } + } + } + return busy; + } + + void logout(CheckContext context) throws IOException { + AutoLoginPlugin autoLogin = Microbot.getPlugin(AutoLoginPlugin.class); + if (autoLogin != null && pluginManager.isPluginActive(autoLogin)) { + Microbot.stopPlugin(autoLogin); + context.detail("autoLoginStopped", waitFor(() -> !pluginManager.isPluginActive(autoLogin), 10_000)); + } + if (Microbot.isLoggedIn()) { + context.detail("tabBeforeLogout", Rs2Tab.getCurrentTab()); + context.detail("worldSwitcherOpenBeforeLogout", worldSwitcherOpen()); + if (Rs2Bank.isOpen()) { + Rs2Bank.closeBank(); + waitFor(() -> !Rs2Bank.isOpen(), 5000); + } + Rs2Player.logout(); + boolean loggedOut = waitFor(() -> Microbot.getClient().getGameState() == GameState.LOGIN_SCREEN, 20_000); + context.detail("gameStateAfterLogout", Microbot.getClient().getGameState()); + context.require(loggedOut, "Still logged in after Rs2Player.logout (" + Microbot.getClient().getGameState() + ")"); + } else { + context.detail("alreadyLoggedOut", true); + } + + List lines = ClientLogScanner.readCurrentRun(ClientLogScanner.defaultLogFile()); + List errors = ClientLogScanner.findErrors(lines, ClientLogScanner.DEFAULT_ALLOWLIST); + context.detail("logLinesScanned", lines.size()); + context.detail("errorLines", errors.size()); + context.require(errors.isEmpty(), errors.size() + " ERROR line(s) in client.log during the run: " + summarize(errors)); + } + + static String summarize(List errors) { + return errors.stream().limit(5).map(line -> truncate(line, 300)).collect(Collectors.joining(" || ")) + + (errors.size() > 5 ? " (+" + (errors.size() - 5) + " more)" : ""); + } + + private static String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max) + "..."; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java new file mode 100644 index 00000000000..42886e7d54e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeHarnessPlugin.java @@ -0,0 +1,168 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.RuneLiteProperties; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.game.WorldService; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.externalplugins.MicrobotPluginManager; +import net.runelite.client.plugins.microbot.testing.TestResultWriter; + +import javax.inject.Inject; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +@PluginDescriptor( + name = ReleaseSmokeHarnessPlugin.SCRIPT_NAME, + description = "Runs the release end-to-end smoke checks in test mode", + tags = {"microbot", "test", "release", "e2e"}, + enabledByDefault = false, + hidden = true +) +@Slf4j +public class ReleaseSmokeHarnessPlugin extends Plugin { + + static final String SCRIPT_NAME = "Release Smoke Harness"; + static final String PREFIX = "microbot.test.release."; + static final long DEFAULT_QUICK_TOTAL_MS = 900_000L; + static final long DEFAULT_FULL_TOTAL_MS = 2_100_000L; + + @Inject + private PluginManager pluginManager; + + @Inject + private ConfigManager configManager; + + @Inject + private MicrobotPluginManager microbotPluginManager; + + @Inject + private WorldService worldService; + + private ExecutorService executor; + + @Override + protected void startUp() { + if (!isHarnessTarget()) { + return; + } + executor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("ReleaseE2E-harness-%d") + .build()); + executor.submit(this::runHarness); + } + + @Override + protected void shutDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + private void runHarness() { + ReleaseSmokeResult result = new ReleaseSmokeResult(SCRIPT_NAME); + Path outputDir = Paths.get(TestResultWriter.getDefaultOutputDir()); + try { + ReleaseTier tier = ReleaseTier.parse(property("tier", "quick")); + List requested = parseIds(property("checks", "")); + boolean failFast = Boolean.parseBoolean(property("failFast", "false")); + long totalTimeout = longProperty("totalTimeoutMs", tier == ReleaseTier.FULL ? DEFAULT_FULL_TOTAL_MS : DEFAULT_QUICK_TOTAL_MS); + + ReleaseSmokeChecks.Settings settings = new ReleaseSmokeChecks.Settings(); + settings.expectedVersion = property("expectedVersion", ""); + settings.hubPlugin = property("hubPlugin", ReleaseSmokeChecks.DEFAULT_HUB_PLUGIN); + settings.hopWorld = (int) longProperty("hopWorld", -1); + settings.loginTimeoutMs = longProperty("loginTimeoutMs", settings.loginTimeoutMs); + + result.tier = tier.name(); + result.failFast = failFast; + result.totalTimeoutMs = totalTimeout; + result.requestedChecks = requested; + result.expectedVersion = settings.expectedVersion; + result.microbotVersion = RuneLiteProperties.getMicrobotVersion(); + result.microbotCommit = RuneLiteProperties.getMicrobotCommit(); + result.runeliteVersion = RuneLiteProperties.getVersion(); + + ReleaseSmokeChecks checks = new ReleaseSmokeChecks(settings, pluginManager, configManager, microbotPluginManager, worldService); + List selected = ReleaseCheckRunner.select(checks.all(), tier, requested); + + if (!sleepUntil(() -> ReleaseSmokeLive.location() != null, 60_000)) { + result.addError("Player location never became available after login"); + result.finish("login_failure"); + writeAndExit(result, outputDir); + return; + } + result.world = Microbot.getClient().getWorld(); + + log.info("[ReleaseE2E] Running {} check(s), tier={}, failFast={}, totalTimeoutMs={}: {}", selected.size(), tier, failFast, + totalTimeout, selected.stream().map(ReleaseCheck::getId).collect(Collectors.joining(", "))); + ReleaseCheckRunner runner = new ReleaseCheckRunner(new ReleaseCheckRunner.Options() + .failFast(failFast) + .totalTimeoutMs(totalTimeout)); + runner.run(selected, outcome -> { + result.record(outcome); + log.info("[ReleaseE2E] {} {} in {}ms{}", outcome.id, outcome.status, outcome.durationMs, + outcome.detail == null ? "" : ": " + outcome.detail); + }); + result.finish("completed"); + } catch (IllegalArgumentException e) { + result.addError(e.getMessage()); + result.finish("invalid_arguments"); + } catch (Throwable t) { + log.warn("[ReleaseE2E] Harness crashed", t); + result.addError(ReleaseCheckRunner.describe(t)); + result.finish("crash"); + } + writeAndExit(result, outputDir); + } + + private static void writeAndExit(ReleaseSmokeResult result, Path outputDir) { + TestResultWriter.write(result, outputDir.toString()); + try { + Path summary = ReleaseSmokeReport.write(result, outputDir); + log.info("[ReleaseE2E] Summary written to {}\n{}", summary, ReleaseSmokeReport.render(result)); + } catch (Exception e) { + log.warn("[ReleaseE2E] Could not write summary", e); + } + System.exit(result.exitCode); + } + + static List parseIds(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return Arrays.stream(value.split("[,\\s]+")) + .map(String::trim) + .filter(id -> !id.isEmpty()) + .collect(Collectors.toList()); + } + + private static String property(String key, String defaultValue) { + String value = System.getProperty(PREFIX + key); + return value == null || value.isBlank() ? defaultValue : value.trim(); + } + + private static long longProperty(String key, long defaultValue) { + try { + return Long.parseLong(property(key, String.valueOf(defaultValue))); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static boolean isHarnessTarget() { + return "true".equals(System.getProperty("microbot.test.mode")) + && System.getProperty("microbot.test.script", "").contains(SCRIPT_NAME); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java new file mode 100644 index 00000000000..8cfc316234f --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeLive.java @@ -0,0 +1,276 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import net.runelite.api.GameState; +import net.runelite.api.Tile; +import net.runelite.api.WallObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; +import net.runelite.client.plugins.microbot.api.tileobject.models.TileObjectType; +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.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.WalkerState; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.BooleanSupplier; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +final class ReleaseSmokeLive { + + static final class WalkResult { + final String state; + final WorldPoint end; + final int distance; + final long durationMs; + final int maxJump; + final String maxJumpEdge; + + WalkResult(String state, WorldPoint end, int distance, long durationMs, int maxJump, String maxJumpEdge) { + this.state = state; + this.end = end; + this.distance = distance; + this.durationMs = durationMs; + this.maxJump = maxJump; + this.maxJumpEdge = maxJumpEdge; + } + + boolean arrived(int tolerance) { + return WalkerState.ARRIVED.name().equals(state) && distance <= tolerance; + } + + String describe() { + return "state=" + state + ", end=" + format(end) + ", distance=" + distance + ", " + durationMs + "ms"; + } + } + + static final class Door { + final WorldPoint location; + final int id; + final String name; + final List actions; + + Door(WorldPoint location, int id, String name, List actions) { + this.location = location; + this.id = id; + this.name = name; + this.actions = actions; + } + + boolean isClosed() { + return actions.stream().anyMatch("Open"::equalsIgnoreCase); + } + + boolean isOpen() { + return actions.stream().anyMatch("Close"::equalsIgnoreCase); + } + + String describe() { + return name + "#" + id + "@" + format(location) + actions; + } + } + + private ReleaseSmokeLive() { + } + + static WorldPoint location() { + return Rs2Player.getWorldLocation(); + } + + static boolean loggedIn() { + return Microbot.getClient().getGameState() == GameState.LOGGED_IN && Microbot.isLoggedIn(); + } + + static void requireLoggedIn(CheckContext context) { + context.require(loggedIn(), "Client is not logged in (game state " + Microbot.getClient().getGameState() + ")"); + } + + static boolean waitFor(BooleanSupplier condition, int timeoutMs) { + return sleepUntil(condition, timeoutMs); + } + + static WalkResult walk(WorldPoint destination, int tolerance, long timeoutMs) { + ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("ReleaseE2E-walk-%d") + .setDaemon(true) + .build()); + long start = System.currentTimeMillis(); + Future future = executor.submit(() -> Rs2Walker.walkWithState(destination, tolerance)); + WorldPoint previous = location(); + int maxJump = 0; + String maxJumpEdge = null; + String state; + try { + long deadline = start + timeoutMs; + while (!future.isDone() && System.currentTimeMillis() < deadline) { + if (Thread.currentThread().isInterrupted()) { + break; + } + sleepUntil(future::isDone, 600); + WorldPoint current = location(); + if (previous != null && current != null) { + int jump = current.distanceTo2D(previous); + if (jump > maxJump) { + maxJump = jump; + maxJumpEdge = format(previous) + "->" + format(current); + } + } + if (current != null) { + previous = current; + } + } + if (future.isDone()) { + state = future.get().name(); + } else { + future.cancel(true); + Rs2Walker.setTarget(null); + state = Thread.currentThread().isInterrupted() ? "INTERRUPTED" : "TIMEOUT"; + } + } catch (InterruptedException e) { + future.cancel(true); + Rs2Walker.setTarget(null); + Thread.currentThread().interrupt(); + state = "INTERRUPTED"; + } catch (Exception e) { + Rs2Walker.setTarget(null); + state = "ERROR:" + ReleaseCheckRunner.describe(e.getCause() == null ? e : e.getCause()); + } finally { + executor.shutdownNow(); + } + WorldPoint end = location(); + return new WalkResult(state, end, distance(end, destination), System.currentTimeMillis() - start, maxJump, maxJumpEdge); + } + + static WalkResult walkOrFail(CheckContext context, String label, WorldPoint destination, int tolerance, long timeoutMs) { + WalkResult result = walk(destination, tolerance, timeoutMs); + context.detail(label, result.describe()); + context.require(result.arrived(tolerance), label + " to " + format(destination) + " failed: " + result.describe()); + return result; + } + + static WalkResult walkToStart(CheckContext context, String label, WorldPoint destination, int tolerance, long timeoutMs) { + WorldPoint current = location(); + if (current != null && current.getPlane() == destination.getPlane() && current.distanceTo(destination) <= tolerance) { + context.detail(label, "already within " + tolerance + " of " + format(destination)); + return null; + } + return walkOrFail(context, label, destination, tolerance, timeoutMs); + } + + static List doorsNear(WorldPoint anchor, int radius) { + List models = Microbot.getRs2TileObjectCache().query() + .withNames("Door", "Gate", "Large door") + .within(anchor, radius) + .toListOnClientThread(); + return models.stream() + .filter(model -> model.getTileObjectType() == TileObjectType.WALL) + .map(ReleaseSmokeLive::toDoor) + .filter(door -> door.isOpen() || door.isClosed()) + .sorted(Comparator.comparingInt(door -> door.location.distanceTo(anchor))) + .collect(Collectors.toList()); + } + + static Optional doorAt(WorldPoint anchor, int radius) { + return doorsNear(anchor, radius).stream().findFirst(); + } + + private static Door toDoor(Rs2TileObjectModel model) { + String[] actions = model.getObjectComposition().getActions(); + List cleaned = actions == null ? List.of() : Arrays.stream(actions) + .filter(action -> action != null && !action.isBlank()) + .collect(Collectors.toList()); + return new Door(model.getWorldLocation(), model.getId(), model.getName(), cleaned); + } + + static boolean clickDoor(WorldPoint location, String action) { + List models = Microbot.getRs2TileObjectCache().query() + .withNames("Door", "Gate", "Large door") + .within(location, 0) + .toListOnClientThread(); + for (Rs2TileObjectModel model : models) { + if (model.getTileObjectType() == TileObjectType.WALL && toDoor(model).actions.stream().anyMatch(action::equalsIgnoreCase)) { + return model.click(action); + } + } + return false; + } + + static boolean setDoorState(CheckContext context, String label, WorldPoint anchor, boolean wantClosed, int timeoutMs) { + Optional current = doorAt(anchor, 1); + if (current.isEmpty()) { + context.detail(label, "no door within 1 tile of " + format(anchor)); + return false; + } + if (current.get().isClosed() == wantClosed) { + context.detail(label, "already " + (wantClosed ? "closed" : "open") + ": " + current.get().describe()); + return true; + } + String action = wantClosed ? "Close" : "Open"; + boolean clicked = clickDoor(current.get().location, action); + boolean changed = waitFor(() -> doorAt(anchor, 1).map(door -> door.isClosed() == wantClosed).orElse(false), timeoutMs); + context.detail(label, action + " clicked=" + clicked + ", changed=" + changed + ", now=" + + doorAt(anchor, 1).map(Door::describe).orElse("none")); + return changed; + } + + static Optional wallOrientation(WorldPoint point) { + return wallObjectAt(point).map(WallObject::getOrientationA); + } + + private static Optional wallObjectAt(WorldPoint point) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + WorldView worldView = Microbot.getClient().getTopLevelWorldView(); + LocalPoint localPoint = LocalPoint.fromWorld(worldView, point); + if (localPoint == null) { + return null; + } + Tile[][][] tiles = worldView.getScene().getTiles(); + Tile tile = tiles[point.getPlane()][localPoint.getSceneX()][localPoint.getSceneY()]; + return tile == null ? null : tile.getWallObject(); + }); + } + + static WorldPoint acrossWall(WorldPoint doorTile, int orientation) { + switch (orientation) { + case 1: + return doorTile.dx(-1); + case 2: + return doorTile.dy(1); + case 4: + return doorTile.dx(1); + case 8: + return doorTile.dy(-1); + default: + return null; + } + } + + static boolean reachable(WorldPoint point) { + return Rs2Tile.isTileReachable(point); + } + + static int distance(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { + return Integer.MAX_VALUE; + } + if (from.getPlane() != to.getPlane()) { + return Integer.MAX_VALUE - 1; + } + return from.distanceTo(to); + } + + static String format(WorldPoint point) { + return point == null ? "null" : point.getX() + "," + point.getY() + "," + point.getPlane(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java new file mode 100644 index 00000000000..98a301b12a5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeReport.java @@ -0,0 +1,83 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +public final class ReleaseSmokeReport { + + public static final String SUMMARY_FILE = "summary.md"; + private static final int MAX_DETAIL_LENGTH = 240; + + private ReleaseSmokeReport() { + } + + public static String render(ReleaseSmokeResult result) { + StringBuilder out = new StringBuilder(); + out.append("## Microbot release E2E: ").append(result.verdict) + .append(" (").append(result.tier == null ? "?" : result.tier.toLowerCase(Locale.ROOT)).append(" tier)\n\n"); + out.append("- Microbot ").append(nullToDash(result.microbotVersion)) + .append(" (commit ").append(nullToDash(result.microbotCommit)).append("), RuneLite ") + .append(nullToDash(result.runeliteVersion)).append('\n'); + if (result.expectedVersion != null && !result.expectedVersion.isBlank()) { + out.append("- Expected version: ").append(result.expectedVersion).append('\n'); + } + out.append("- Started: ").append(result.timestamp).append(", total ").append(formatDuration(result.durationMs)) + .append(result.failFast ? ", fail-fast" : "").append('\n'); + out.append("- Result: ").append(result.passed).append(" passed, ").append(result.failed).append(" failed, ") + .append(result.skipped).append(" skipped, ").append(result.notRun).append(" not run") + .append(" (exit ").append(result.exitReason).append(", code ").append(result.exitCode).append(")\n"); + for (String error : result.errors) { + out.append("- Harness error: ").append(escape(error)).append('\n'); + } + out.append('\n'); + out.append("| # | Check | Tier | Status | Duration | Detail |\n"); + out.append("|---|---|---|---|---|---|\n"); + for (CheckOutcome outcome : result.results) { + out.append("| ").append(outcome.order) + .append(" | `").append(outcome.id).append("` ") + .append(escape(outcome.description)) + .append(" | ").append(outcome.tier == null ? "" : outcome.tier.name().toLowerCase(Locale.ROOT)) + .append(" | ").append(outcome.status) + .append(" | ").append(outcome.status == CheckStatus.NOT_RUN ? "-" : formatDuration(outcome.durationMs)) + .append(" | ").append(escape(truncate(outcome.detail))) + .append(" |\n"); + } + return out.toString(); + } + + public static Path write(ReleaseSmokeResult result, Path outputDir) throws IOException { + Files.createDirectories(outputDir); + Path file = outputDir.resolve(SUMMARY_FILE); + Files.write(file, render(result).getBytes(StandardCharsets.UTF_8)); + return file; + } + + static String formatDuration(long durationMs) { + long seconds = Math.max(0L, durationMs) / 1000L; + if (seconds < 60) { + return String.format(Locale.ROOT, "%.1fs", Math.max(0L, durationMs) / 1000.0); + } + return String.format(Locale.ROOT, "%dm%02ds", seconds / 60, seconds % 60); + } + + private static String truncate(String value) { + if (value == null) { + return ""; + } + return value.length() <= MAX_DETAIL_LENGTH ? value : value.substring(0, MAX_DETAIL_LENGTH - 3) + "..."; + } + + private static String escape(String value) { + if (value == null) { + return ""; + } + return value.replace("|", "\\|").replace("\r", " ").replace("\n", " "); + } + + private static String nullToDash(String value) { + return value == null || value.isBlank() ? "-" : value; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java new file mode 100644 index 00000000000..147e287ec64 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResult.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import net.runelite.client.plugins.microbot.testing.TestResult; + +import java.util.ArrayList; +import java.util.List; + +public class ReleaseSmokeResult extends TestResult { + public String tier; + public boolean failFast; + public long totalTimeoutMs; + public List requestedChecks = new ArrayList<>(); + public String microbotVersion; + public String microbotCommit; + public String runeliteVersion; + public String expectedVersion; + public int world; + public long passed; + public long failed; + public long skipped; + public long notRun; + public String verdict; + public List results = new ArrayList<>(); + + public ReleaseSmokeResult(String script) { + super(script); + } + + public void record(CheckOutcome outcome) { + results.add(outcome); + addCheck(outcome.id, outcome.status.isGreen(), + outcome.status == CheckStatus.PASSED ? null : outcome.status + ": " + outcome.detail); + } + + public void finish(String exitReason) { + passed = count(CheckStatus.PASSED); + failed = count(CheckStatus.FAILED); + skipped = count(CheckStatus.SKIPPED); + notRun = count(CheckStatus.NOT_RUN); + complete(exitReason); + if ("completed".equals(exitReason) && results.isEmpty()) { + exitCode = 1; + } + verdict = exitCode == 0 ? "PASS" : "FAIL"; + } + + private long count(CheckStatus status) { + return results.stream().filter(outcome -> outcome.status == status).count(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java new file mode 100644 index 00000000000..3b0e79adab0 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/release/ReleaseTier.java @@ -0,0 +1,23 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import java.util.Locale; + +public enum ReleaseTier { + QUICK, + FULL; + + public boolean includes(ReleaseTier checkTier) { + return this == FULL || checkTier == QUICK; + } + + public static ReleaseTier parse(String value) { + if (value == null || value.isBlank()) { + return QUICK; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unknown release E2E tier '" + value + "', expected quick or full", e); + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java index 61b80a6a309..fdfbb862edf 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/MicrobotProfilePanel.java @@ -334,7 +334,7 @@ private void renameProfile(long id, String name) { return; } - log.info("Renaming profile {} ({}) to {}", profile, profile.getId(), name); + log.info("Renaming profile {}", profile.getId()); lock.renameProfile(profile, name); configManager.renameProfile(profile, name); @@ -351,7 +351,7 @@ private void createProfile() { name = "New Profile (" + (number++) + ")"; } - log.info("Creating new profile: {}", name); + log.info("Creating new profile"); lock.createProfile(name); reload(lock.getProfiles()); @@ -359,7 +359,7 @@ private void createProfile() { } private void deleteProfile(ConfigProfile profile) { - log.info("Deleting profile {}", profile.getName()); + log.info("Deleting profile {}", profile.getId()); configManager.toggleSync(profile, false); @@ -464,7 +464,7 @@ private void setRsProfileDefaultProfile(long id) { } private void exportProfile(ConfigProfile profile, File file) { - log.info("Exporting profile {} to {}", profile.getName(), file); + log.info("Exporting profile {}", profile.getId()); executor.execute(() -> { configManager.sendConfig(); @@ -1099,7 +1099,7 @@ private void cloneProfile(ConfigProfile profile) { } while (lock.findProfile(name) != null); - log.info("Cloning profile {} to {}", profile.getName(), name); + log.info("Cloning profile {}", profile.getId()); ConfigProfile clonedProfile = lock.createProfile(name); reload(lock.getProfiles()); @@ -1122,7 +1122,7 @@ private void cloneProfile(ConfigProfile profile) { } private void toggleSync(ActionEvent event, ConfigProfile profile, boolean sync) { - log.info("{} sync for: {}", sync ? "Enabling" : "Disabling", profile.getName()); + log.info("{} sync for: {}", sync ? "Enabling" : "Disabling", profile.getId()); configManager.toggleSync(profile, sync); ((JToggleButton) event.getSource()).setToolTipText(sync ? "Disable cloud sync" : "Enable cloud sync"); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java index aa0fc354198..404fa9e42ff 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/ui/WorldSelectorDialog.java @@ -211,7 +211,7 @@ private void loadWorlds() { return; } - List worlds = worldResult.getWorlds(); + List worlds = new ArrayList<>(worldResult.getWorlds()); // Sort by world ID worlds.sort(Comparator.comparingInt(World::getId)); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index 4de721260de..b0a98b7c4a2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -198,6 +198,23 @@ private static void addRuneLikePouchRows(List out, List _mainScheduler; + /** Required-supply failure behavior; existing callers retain the global pause. */ + public enum MissingSupplyPolicy { PAUSE_ALL_SCRIPTS, LOGOUT_AND_STOP } + + private MissingSupplyPolicy missingSupplyPolicy = MissingSupplyPolicy.PAUSE_ALL_SCRIPTS; + + /** + * Selects behavior for confirmed required-supply shortages on this loader only. + * Set this policy before loading; terminal logout must be explicitly requested by the owning script. + * + * @param policy the required-supply failure policy + * @return this loader for chaining after either constructor + */ + public Rs2InventorySetup withMissingSupplyPolicy(MissingSupplyPolicy policy) { + missingSupplyPolicy = Objects.requireNonNull(policy, "policy"); + return this; + } + /** * Constructor to initialize the Rs2InventorySetup with a specific setup name and scheduler. * @@ -383,7 +400,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { toleratedShortfallKeys.add(inventoryShortfallKey(item)); continue; } - Microbot.pauseAllScripts.compareAndSet(false, true); + stopForMissingRequiredSupply(item.getName()); logSetup(Level.WARN, "bank short: %s | bank=%d | inv=%d | setup_total=%d | withdraw=%d (id=%d fuzzy=%b noted=%b)", item.getName(), bankAvail, invQty, setupTotal, desiredWithdraw, @@ -414,7 +431,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { toleratedShortfallKeys.add(inventoryShortfallKey(item)); continue; } - Microbot.pauseAllScripts.compareAndSet(false, true); + stopForMissingRequiredSupply(item.getName()); logSetup(Level.WARN, "bank short: %s | bank=%d | inv=%d | setup_total=%d | withdraw=%d (id=%d fuzzy=%b noted=%b)", item.getName(), bankAvail, invQty, setupTotal, withdrawQuantity, @@ -863,9 +880,11 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { int bankGear = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); int invGear = isFuzzy ? Rs2Inventory.itemQuantity((String) identifier) : Rs2Inventory.itemQuantity((int) identifier); logSetup(Level.WARN, - "missing gear %s | bank=%d | inv=%d | need=%d — pausing", - item.getName(), bankGear, invGear, item.getQuantity()); - Microbot.pauseAllScripts.compareAndSet(false, true); + "missing gear %s | bank=%d | inv=%d | need=%d — %s", + item.getName(), bankGear, invGear, item.getQuantity(), + missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP ? "stopping" : "pausing"); + stopForMissingRequiredSupply(item.getName()); + if (missingSupplyPolicy == MissingSupplyPolicy.LOGOUT_AND_STOP) return false; continue; } @@ -901,6 +920,17 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { return doesEquipmentMatch(); } + private void stopForMissingRequiredSupply(String itemName) { + if (missingSupplyPolicy == MissingSupplyPolicy.PAUSE_ALL_SCRIPTS) { + Microbot.pauseAllScripts.compareAndSet(false, true); + return; + } + Microbot.status = "Missing required setup supply: " + itemName; + Rs2Player.logoutWithoutAutoLogin(); + // A later script start must not revive this run's failing bank loop. + if (_mainScheduler != null) _mainScheduler.cancel(false); + } + /** * Wears the equipment items defined in the inventory setup. * Iterates through the equipment setup and equips the items to the player. diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java index 4ced635ac61..39f9145509d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanOverlay.java @@ -3,6 +3,7 @@ import net.runelite.api.Actor; import net.runelite.api.Point; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.antiban.enums.PlayStyle; import net.runelite.client.ui.overlay.Overlay; import net.runelite.client.ui.overlay.OverlayPosition; import net.runelite.client.ui.overlay.components.ProgressPieComponent; @@ -118,7 +119,11 @@ private void drawTimerPieOverlay(Graphics2D graphics) { // Calculate the remaining time as a fraction of the total time - int totalTime = Rs2Antiban.getPlayStyle().getSecondaryTickInterval(); + PlayStyle playStyle = Rs2Antiban.getPlayStyle(); + if (playStyle == null) { + return; + } + int totalTime = playStyle.getSecondaryTickInterval(); int timeLeft = Rs2Antiban.getTIMEOUT(); float percent = (float) timeLeft / totalTime; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java index 1e9d64832e2..3caa1459fd5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/AntibanPlugin.java @@ -180,6 +180,7 @@ protected void startUp() throws AWTException { panelRefreshTimer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { + panel.refreshBusyState(); SwingUtilities.invokeLater(panel::loadSettings); } }, 0, 600); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java index a8268a483ef..b712ee43a7d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/Rs2Antiban.java @@ -286,14 +286,31 @@ private static void logDebug(String message) { } } + static PlayStyle resolvePlayStyle() { + PlayStyle current = playStyle; + if (current != null) + return current; + + ActivityIntensity intensity = activityIntensity != null ? activityIntensity : ActivityIntensity.EXTREME; + current = Rs2AntibanSettings.randomIntervals ? PlayStyle.RANDOM : intensity.getPlayStyle(); + current.frequency = intensity.getFrequency(); + current.amplitude = intensity.getAmplitude(); + current.resetPlayStyle(); + playStyle = current; + logDebug("No play style set, defaulting to " + current.getName()); + return current; + } + private static void performActionCooldown() { + PlayStyle current = resolvePlayStyle(); + if (Rs2AntibanSettings.nonLinearIntervals) - playStyle.evolvePlayStyle(); + current.evolvePlayStyle(); if (Rs2AntibanSettings.behavioralVariability) - TIMEOUT = playStyle.getRandomTickInterval(); + TIMEOUT = current.getRandomTickInterval(); else - TIMEOUT = playStyle.getPrimaryTickInterval(); + TIMEOUT = current.getPrimaryTickInterval(); // The pause (universal antiban only) and the always-set active flag both happen only after // TIMEOUT is computed: if computing the interval ever throws, scripts must not be left diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java index 3cb494fb473..8abd02d40bf 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/antiban/ui/MasterPanel.java @@ -4,6 +4,7 @@ import net.runelite.client.plugins.microbot.util.antiban.AntibanPlugin; import net.runelite.client.plugins.microbot.util.antiban.Rs2Antiban; import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; +import net.runelite.client.plugins.microbot.util.antiban.enums.Category; import net.runelite.client.ui.FontManager; import net.runelite.client.ui.PluginPanel; import net.runelite.client.util.ImageUtil; @@ -52,7 +53,8 @@ public class MasterPanel extends PluginPanel { private final JLabel profileLabel = new JLabel("Category: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().getName() : "null")); private final JLabel activityLabel = new JLabel("Activity: " + (Rs2Antiban.getActivity() != null ? Rs2Antiban.getActivity().getMethod() : "null")); private final JLabel activityIntensityLabel = new JLabel("Activity Intensity: " + (Rs2Antiban.getActivityIntensity() != null ? Rs2Antiban.getActivityIntensity().getName() : "null")); - private final JLabel busyLabel = new JLabel("Busy: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().isBusy() : "null")); + private final JLabel busyLabel = new JLabel("Busy: null"); + private volatile String busyText = "Busy: null"; private final boolean isFlipped = false; // Track if the image is flipped private final FlippableLabel label; private final JLayeredPane layeredPane; // Use a layered pane for positioning the GIF @@ -203,7 +205,12 @@ public void loadSettings() { profileLabel.setText("Category: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().getName() : "null")); activityLabel.setText("Activity: " + (Rs2Antiban.getActivity() != null ? Rs2Antiban.getActivity().getMethod() : "null")); activityIntensityLabel.setText("Activity Intensity: " + (Rs2Antiban.getActivityIntensity() != null ? Rs2Antiban.getActivityIntensity().getName() : "null")); - busyLabel.setText("Busy: " + (Rs2Antiban.getCategory() != null ? Rs2Antiban.getCategory().isBusy() : "null")); + busyLabel.setText(busyText); + } + + public void refreshBusyState() { + Category category = Rs2Antiban.getCategory(); + busyText = "Busy: " + (category != null ? category.isBusy() : "null"); } private JPanel createInfoPanel() { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 0caa5035090..3f3ef5433f3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -2397,6 +2397,7 @@ private static NearestBankRoute getPathAndBankToNearestBank(WorldPoint worldPoin private static NearestBankRoute getPathAndBankToNearestBank( WorldPoint worldPoint, int maxObjectSearchRadius, Boolean useBankItems) { + if (worldPoint == null) return null; Microbot.log("Finding nearest bank..."); Set allBanks = Arrays.stream(BankLocation.values()) @@ -2561,8 +2562,9 @@ public static boolean walkToBank(BankLocation bankLocation, boolean toggleRun) { Rs2Player.toggleRunEnergy(toggleRun); Microbot.status = "Walking to nearest bank " + bankLocation.toString(); walkUntilBankReady(bankLocation); - return readyBankObject(bankLocation) != null - || bankLocation.getWorldPoint().distanceTo(Rs2Player.getWorldLocation()) <= 4; + if (readyBankObject(bankLocation) != null) return true; + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + return playerLocation != null && bankLocation.getWorldPoint().distanceTo(playerLocation) <= 4; } /** @@ -2573,6 +2575,7 @@ public static boolean walkToBank(BankLocation bankLocation, boolean toggleRun) { */ public static boolean isNearBank(int distance) { WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return false; return Arrays.stream(BankLocation.values()) .anyMatch(b -> b.getWorldPoint().getPlane() == playerLocation.getPlane() && b.getWorldPoint().distanceTo2D(playerLocation) <= distance); @@ -2586,8 +2589,9 @@ public static boolean isNearBank(int distance) { * @return true if Rs2Player location is less than distance away from the bank location */ public static boolean isNearBank(BankLocation bankLocation, int distance) { - int distanceToBank = Rs2Walker.getDistanceBetween(Rs2Player.getWorldLocation(), bankLocation.getWorldPoint()); - return distanceToBank <= distance; + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return false; + return Rs2Walker.getDistanceBetween(playerLocation, bankLocation.getWorldPoint()) <= distance; } /** @@ -2648,7 +2652,7 @@ public static boolean handleBankPin(String pin) { final String[] DIGIT_INSTRUCTIONS = {"FIRST digit", "SECOND digit", "THIRD digit", "FOURTH digit"}; if (pin == null || pin.length() != DIGIT_INSTRUCTIONS.length || !pin.matches("\\d+")) { - Microbot.log("Unable to enter bankpin with value " + pin); + Microbot.log("Unable to enter bankpin: configured pin is not a 4-digit number"); return false; } @@ -2939,6 +2943,7 @@ public static boolean depositLootingBag(){ * @param npcName the name of the npc * @return {@code true} if successfully withdrew loot items, otherwise {@code false} */ + @Deprecated public static boolean withdrawLootItems(String npcName, List itemsToNotSell) { boolean isAtGe = Rs2GrandExchange.walkToGrandExchange(); if (isAtGe) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java index afc1a52ed0b..625742cc67b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPoint.java @@ -11,6 +11,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; @Slf4j public class Rs2WorldPoint { @@ -52,6 +53,10 @@ public List pathTo(WorldPoint other) public List pathTo(WorldPoint other, boolean fullPath) { + if (worldPoint == null || other == null) + { + return null; + } Client client = Microbot.getClient(); if (getPlane() != other.getPlane()) { @@ -181,19 +186,19 @@ public boolean equals(Object obj) { if (this == obj) return true; if (obj instanceof Rs2WorldPoint) { Rs2WorldPoint other = (Rs2WorldPoint) obj; - return worldPoint.equals(other.worldPoint); + return Objects.equals(worldPoint, other.worldPoint); } return false; } @Override public int hashCode() { - return worldPoint.hashCode(); + return Objects.hashCode(worldPoint); } @Override public String toString() { - return worldPoint.toString(); + return String.valueOf(worldPoint); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java index 20f3783d5ec..29f3bfd3378 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2Dialogue.java @@ -153,11 +153,25 @@ public static boolean hasSelectAnOption() { public static String getQuestion() { if (!hasSelectAnOption()) return null; - Widget[] dynamicWidgetOptions = Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1).getDynamicChildren(); - if (dynamicWidgetOptions != null && dynamicWidgetOptions.length > 0) { - return Rs2UiHelper.stripColTags(dynamicWidgetOptions[0].getText()); - } - return null; + String text = getDialogOptionTitleText(); + return text == null ? null : Rs2UiHelper.stripColTags(text); + } + + private static String getDialogOptionTitleText() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget dialogueOption = Microbot.getClient().getWidget(InterfaceID.DIALOG_OPTION, 1); + if (dialogueOption == null) return null; + Widget[] children = dialogueOption.getDynamicChildren(); + if (children == null || children.length == 0 || children[0] == null) return null; + return children[0].getText(); + }).orElse(null); + } + + private static Widget[] getDialogOptionChildren() { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget dialogueOption = Microbot.getClient().getWidget(InterfaceID.DIALOG_OPTION, 1); + return dialogueOption == null ? null : dialogueOption.getDynamicChildren(); + }).orElse(new Widget[0]); } /** @@ -193,15 +207,13 @@ public static boolean hasQuestion(String text) { public static boolean hasDialogueOptionTitle(String text, boolean exact) { if (!hasSelectAnOption()) return false; - Widget dialogueOption = Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1); - if (dialogueOption == null) return false; - Widget[] dynamicWidgetOptions = dialogueOption.getDynamicChildren(); - if (dynamicWidgetOptions == null || dynamicWidgetOptions.length == 0 || dynamicWidgetOptions[0] == null) return false; + String title = getDialogOptionTitleText(); + if (title == null) return false; if (exact) { - return dynamicWidgetOptions[0].getText().equalsIgnoreCase(text); + return title.equalsIgnoreCase(text); } else { - return dynamicWidgetOptions[0].getText().toLowerCase().contains(text.toLowerCase()); + return title.toLowerCase().contains(text.toLowerCase()); } } @@ -225,17 +237,16 @@ public static List getDialogueOptions() { if (!hasSelectAnOption()) return Collections.emptyList(); List out = new ArrayList<>(); - Widget dialogueOption = Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1); - if (dialogueOption == null) return new ArrayList<>(); - Widget[] dynamicWidgetOptions = dialogueOption.getDynamicChildren(); + Widget[] dynamicWidgetOptions = getDialogOptionChildren(); // Skip the first dynamic widget option, as it is never an option for (int i = 1; i < dynamicWidgetOptions.length; i++) { - if (dynamicWidgetOptions[i].getText().isBlank()) { + Widget option = dynamicWidgetOptions[i]; + if (option == null || option.getText() == null || option.getText().isBlank()) { continue; } - out.add(dynamicWidgetOptions[i]); + out.add(option); } return out; @@ -524,8 +535,12 @@ public static List getCombinationOptions() { if (!hasCombinationDialogue()) return Collections.emptyList(); List options = new ArrayList<>(); - if (Rs2Widget.isWidgetVisible(270, 13)) { - for (Widget widget : Rs2Widget.getWidget(270, 14).getStaticChildren()) { + if (!Rs2Widget.isWidgetVisible(270, 13)) return options; + + Widget combinationList = Rs2Widget.getWidget(270, 14); + Widget[] children = combinationList == null ? null : combinationList.getStaticChildren(); + if (children != null) { + for (Widget widget : children) { if (widget != null && widget.getActions() != null && widget.getActions().length > 0) { options.add(widget); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java index b6d61d91b59..68c578b9618 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/discord/Rs2Discord.java @@ -86,18 +86,15 @@ public static boolean sendWebhookMessage(String bodyMessage, List try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - Microbot.log("Failed to send Discord notification. Error Code: " + response.code() - + " - URL marked as invalid: " + webHookUrl); + Microbot.log("Failed to send Discord notification. Error Code: " + response.code()); } return response.isSuccessful(); } } catch (IllegalArgumentException e) { - Microbot.log("Invalid Discord webhook URL format - URL marked as invalid: " + webHookUrl - + " - Error: " + e.getMessage()); + Microbot.log("Invalid Discord webhook URL format"); return false; } catch (IOException e) { - Microbot.log("Error while sending Discord notification to URL: " + webHookUrl - + " - Error: " + e.getMessage()); + Microbot.log("Error while sending Discord notification: " + e.getClass().getSimpleName()); return false; } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java index 6f968e72a68..47b8e891598 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObject.java @@ -671,6 +671,7 @@ public static TileObject getTileObject(Predicate predicate, LocalPoi } public static TileObject getTileObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { // POH fix: see Rs2GameObject.getGameObject(Predicate, WorldPoint, int). @@ -727,6 +728,7 @@ public static List getTileObjects(Predicate predicate, L } public static List getTileObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), anchor); if (anchorLocal == null) { // POH fix: Rs2Player.getWorldLocation() returns the template tile inside a POH @@ -865,6 +867,7 @@ public static GameObject getGameObject(Predicate predicate, LocalPoi } public static GameObject getGameObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { // POH fix: inside a POH instance, the default anchor passed in by the convenience @@ -927,6 +930,7 @@ public static List getGameObjects(Predicate predicate, L } public static List getGameObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), anchor); if (anchorLocal == null) { // POH fix: fall back to player's real LocalLocation when the world anchor doesn't @@ -1054,6 +1058,7 @@ public static GroundObject getGroundObject(Predicate predicate, Lo } public static GroundObject getGroundObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1109,6 +1114,7 @@ public static List getGroundObjects(Predicate predic } public static List getGroundObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), anchor); if (anchorLocal == null) { if (Microbot.getClient().getLocalPlayer() != null) { @@ -1242,6 +1248,7 @@ public static WallObject getWallObject(Predicate predicate, LocalPoi } public static WallObject getWallObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1297,6 +1304,7 @@ public static List getWallObjects(Predicate predicate, L } public static List getWallObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1430,6 +1438,7 @@ public static DecorativeObject getDecorativeObject(Predicate p } public static DecorativeObject getDecorativeObject(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return null; LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { @@ -1485,6 +1494,7 @@ public static List getDecorativeObjects(Predicate getDecorativeObjects(Predicate predicate, WorldPoint anchor, int distance) { + if (anchor == null) return Collections.emptyList(); LocalPoint anchorLocal = localPointFromWorldSafe(anchor); if (anchorLocal == null) { if (Microbot.getClient() != null && Microbot.getClient().getLocalPlayer() != null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java index ec6085b4da8..a233384adb6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/grandexchange/Rs2GrandExchange.java @@ -762,6 +762,7 @@ public static boolean collectAllToBank() { * @param npcName * @return true if there is no more loot to sell */ + @Deprecated public static boolean sellLoot(String npcName, List itemsToNotSell) { boolean withdrewLootItems = Rs2Bank.withdrawLootItems(npcName, itemsToNotSell); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java index f6372422d32..fde57b87b45 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Inventory.java @@ -27,7 +27,6 @@ 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 org.apache.commons.lang3.NotImplementedException; import org.slf4j.event.Level; import java.awt.*; @@ -1330,15 +1329,6 @@ public static boolean isEmpty() { return items().findAny().isEmpty(); } - /** - * Checks whether the inventory is configured to ignore whether shift interactions are enabled or not. - * - * @return True if the inventory ignores shift interactions, false otherwise. - */ - public static boolean isForceNoShift() { - throw new NotImplementedException("TODO"); - } - /** * Determines whether the inventory is full (all slots are occupied). * diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java index 5c3b5bb813a..2fb3e079995 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java @@ -23,12 +23,12 @@ import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; import net.runelite.client.plugins.microbot.util.player.Rs2PlayerModel; import net.runelite.client.plugins.microbot.util.settings.Rs2SpellBookSettings; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; -import org.apache.commons.lang3.NotImplementedException; import java.awt.*; import java.awt.event.KeyEvent; @@ -169,19 +169,23 @@ public static boolean cast(MagicAction magicSpell, String option, int identifier menuAction = MenuAction.WIDGET_TARGET; } - if (magicSpell.getWidgetId() == -1) - throw new NotImplementedException("This spell has not been configured yet in the MagicAction.java class"); + int widgetId = magicSpell.getWidgetId(); + Widget spellWidget = widgetId == -1 ? null : Rs2Widget.getWidget(widgetId); + if (spellWidget == null) { + log("Unable to find the spell widget for " + magicSpell.getName()); + return false; + } Microbot.doInvoke(new NewMenuEntry() .option(option) .param0(-1) - .param1(magicSpell.getWidgetId()) + .param1(widgetId) .opcode(menuAction.getId()) .identifier(identifier) .itemId(-1) .target(magicSpell.getName()) , - new Rectangle(Rs2Widget.getWidget(magicSpell.getWidgetId()).getBounds())); + new Rectangle(spellWidget.getBounds())); //Rs2Reflection.invokeMenu(-1, magicSpell.getWidgetId(), menuAction.getId(), 1, -1, "Cast", "" + magicSpell.getName() + "", -1, -1); return true; } @@ -320,7 +324,8 @@ private static void interact(Rs2ItemModel item, Point point, String info) { } private static void alch(MagicAction magicSpell, Rs2ItemModel item, int sleepMin, int sleepMax) { - final Widget spellWidget = Rs2Widget.getWidget(magicSpell.getWidgetId()); + final int widgetId = magicSpell.getWidgetId(); + final Widget spellWidget = widgetId == -1 ? null : Rs2Widget.getWidget(widgetId); if (spellWidget == null) return; final Point point = new Point((int) spellWidget.getBounds().getCenterX(), (int) spellWidget.getBounds().getCenterY()); @@ -587,7 +592,33 @@ public static Map getMissingRunes(Map reqRunes) } public static Map getMissingRunes(Spell spell, int casts, RuneFilter runeFilter) { - return getMissingRunes(getRequiredRunes(spell, casts), runeFilter); + int sacks = 0; + boolean inWilderness = Rs2Pvp.isInWilderness(); + if (inWilderness && isBlightedIceSpell(spell)) { + if (runeFilter.isIncludeInventory()) sacks += Rs2Inventory.itemQuantity(ItemID.BLIGHTED_SACK_ICEBARRAGE); + if (runeFilter.isIncludeBank()) sacks += Rs2Bank.count(ItemID.BLIGHTED_SACK_ICEBARRAGE); + } + int runeCasts = runeCastsAfterIceSacks(spell, casts, sacks, inWilderness); + return getMissingRunes(getRequiredRunes(spell, runeCasts), runeFilter); + } + + static boolean isBlightedIceSpell(Spell spell) { + if (spell == null || spell.getMagicAction() == null) return false; + switch (spell.getMagicAction()) { + case ICE_RUSH: + case ICE_BURST: + case ICE_BLITZ: + case ICE_BARRAGE: + return true; + default: + return false; + } + } + + /** Sacks cover one whole ice cast each; they must not be credited as generic runes. */ + static int runeCastsAfterIceSacks(Spell spell, int casts, int sacks, boolean inWilderness) { + if (!inWilderness || !isBlightedIceSpell(spell)) return Math.max(0, casts); + return Math.max(0, casts - Math.max(0, sacks)); } public static Map getMissingRunes(Spell spell, int casts) { @@ -614,7 +645,7 @@ public static boolean hasRequiredRunes(Map reqRunes) { } public static boolean hasRequiredRunes(Spell spell, int casts, RuneFilter runeFilter) { - return hasRequiredRunes(getRequiredRunes(spell, casts), runeFilter); + return getMissingRunes(spell, casts, runeFilter).isEmpty(); } public static boolean hasRequiredRunes(Spell spell, int casts) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java index 157a12a9cde..fc198d3fd3b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java @@ -30,6 +30,9 @@ public static boolean isRectangleWithinViewport(Rectangle rectangle) { } public static boolean isRectangleWithinCanvas(Rectangle rectangle) { + if (rectangle == null) { + return false; + } int canvasHeight = Microbot.getClient().getCanvasHeight(); int canvasWidth = Microbot.getClient().getCanvasWidth(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java index 8f04373be24..9f3a1cf7b9e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcModel.java @@ -10,7 +10,6 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.IEntity; import net.runelite.client.plugins.microbot.util.ActorModel; -import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.Nullable; import java.util.Arrays; 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 658e97eb601..3caa11536b8 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 @@ -14,6 +14,8 @@ import net.runelite.api.widgets.Widget; import net.runelite.api.widgets.WidgetInfo; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache; import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; @@ -472,6 +474,38 @@ public static boolean isRunEnabled() { return Microbot.getVarbitPlayerValue(173) == 1; } + public enum TerminalLogoutState { BANK_CLOSING, LOGOUT_REQUESTED, LOGGED_OUT, INPUT_BLOCKED } + + private static TerminalLogoutState terminalLogoutInputStep() { + if (!Microbot.isLoggedIn()) return TerminalLogoutState.LOGGED_OUT; + if (Rs2Bank.isOpen()) { + return Rs2Widget.clickChildWidget(786434, 11) + ? TerminalLogoutState.BANK_CLOSING : TerminalLogoutState.INPUT_BLOCKED; + } + logout(); + return TerminalLogoutState.LOGOUT_REQUESTED; + } + + /** + * Best-effort terminal logout after a confirmed supply failure, off the client thread. + * Pending suppression expires after 15 seconds if logout never completes. Resume by manually logging in + * after the logout, starting another script, or restarting AutoLogin. + * The return value distinguishes requested input from observed logout completion. + */ + public static TerminalLogoutState logoutWithoutAutoLogin() { + if (Microbot.getClient().isClientThread()) return TerminalLogoutState.INPUT_BLOCKED; + // Post once: a newer script start during bank closure must not be re-blocked. + Microbot.getEventBus().post(new AutoLoginSuppressionRequest()); + TerminalLogoutState state = terminalLogoutInputStep(); + if (state == TerminalLogoutState.BANK_CLOSING) { + if (!sleepUntil(() -> !Rs2Bank.isOpen(), 2400)) return state; + state = terminalLogoutInputStep(); + } + if (state == TerminalLogoutState.LOGOUT_REQUESTED + && sleepUntil(() -> !Microbot.isLoggedIn(), 5000)) return TerminalLogoutState.LOGGED_OUT; + return state; + } + /** * Logs the player out of the game */ @@ -914,9 +948,10 @@ public static boolean hasPlayerEquippedItem(Rs2PlayerModel rs2Player, List - Microbot.getClient().getLocalPlayer().getCombatLevel() - ).orElse(0); + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getCombatLevel(); + }).orElse(0); } /** @@ -937,7 +972,10 @@ public static void updateCombatTime() { * @return The local player wrapped in an {@link Rs2PlayerModel}. */ public static Rs2PlayerModel getLocalPlayer() { - return getPlayers(player -> player.getId() == Microbot.getClient().getLocalPlayer().getId(), true).findFirst().orElse(null); + return getPlayers(player -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer != null && player.getId() == localPlayer.getId(); + }, true).findFirst().orElse(null); } /** @@ -1000,11 +1038,13 @@ private static Stream getPlayersMatchingCombatCriteria() { */ public static WorldPoint getWorldLocation_Internal(){ return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player player = Microbot.getClient().getLocalPlayer(); + if (player == null) return null; if (Microbot.getClient().getTopLevelWorldView().getScene().isInstance()) { - LocalPoint l = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), Microbot.getClient().getLocalPlayer().getWorldLocation()); + LocalPoint l = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), player.getWorldLocation()); return WorldPoint.fromLocalInstance(Microbot.getClient(), l); } - return Microbot.getClient().getLocalPlayer().getWorldLocation(); + return player.getWorldLocation(); }).orElse(null); } @@ -1164,7 +1204,8 @@ public static boolean isPlayerAreaIntersecting(WorldPoint targetPoint, int targe * @return The {@link LocalPoint} representing the player's current position. */ public static LocalPoint getLocalLocation() { - return Microbot.getClient().getLocalPlayer().getLocalLocation(); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getLocalLocation(); } /** @@ -1527,9 +1568,10 @@ public static int getAnimation() { * @return The pose animation ID of the player. */ public static int getPoseAnimation() { - return Microbot.getClientThread().runOnClientThreadOptional(() -> - Microbot.getClient().getLocalPlayer().getPoseAnimation() - ).orElse(-1); + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? null : localPlayer.getPoseAnimation(); + }).orElse(-1); } /** @@ -1726,7 +1768,8 @@ public static boolean hasStaminaActive() { * @return The graphic ID of the local player. */ public static int getGraphicId() { - return Microbot.getClient().getLocalPlayer().getGraphic(); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer == null ? -1 : localPlayer.getGraphic(); } /** @@ -1739,7 +1782,8 @@ public static int getGraphicId() { * @return {@code true} if the local player has the specified spot animation, {@code false} otherwise. */ public static boolean hasSpotAnimation(int graphicId) { - return Microbot.getClient().getLocalPlayer().hasSpotAnim(graphicId); + Player localPlayer = Microbot.getClient().getLocalPlayer(); + return localPlayer != null && localPlayer.hasSpotAnim(graphicId); } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java index 51d62a5cfa7..7bb4ef41f0a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerModel.java @@ -8,7 +8,6 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.IEntity; import net.runelite.client.plugins.microbot.util.ActorModel; -import org.apache.commons.lang3.NotImplementedException; @Getter public class Rs2PlayerModel extends ActorModel implements Player { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java new file mode 100644 index 00000000000..152a30dd1a0 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/LoginLockout.java @@ -0,0 +1,104 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.function.DoubleSupplier; +import java.util.function.LongSupplier; + +public final class LoginLockout { + public static final int LOGIN_INDEX_TOO_MANY_ATTEMPTS = 33; + + static final long[] COOLDOWN_STEPS_MILLIS = { + TimeUnit.MINUTES.toMillis(5), + TimeUnit.MINUTES.toMillis(10), + TimeUnit.MINUTES.toMillis(20), + TimeUnit.MINUTES.toMillis(30) + }; + static final double MIN_JITTER = 0.85; + static final double JITTER_RANGE = 0.3; + static final long DISMISS_INTERVAL_MILLIS = 10_000L; + + private static final LoginLockout INSTANCE = new LoginLockout(); + + private final LongSupplier clock; + private final DoubleSupplier random; + private int consecutiveLockouts; + private boolean active; + private long cooldownUntil; + private boolean dismissedOnce; + private boolean awaitingScreenClear; + private long lastDismissAt; + + LoginLockout() { + this(() -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime()), + () -> ThreadLocalRandom.current().nextDouble()); + } + + LoginLockout(LongSupplier clock, DoubleSupplier random) { + this.clock = clock; + this.random = random; + } + + public static LoginLockout getInstance() { + return INSTANCE; + } + + public static boolean isLockoutLoginIndex(int loginIndex) { + return loginIndex == LOGIN_INDEX_TOO_MANY_ATTEMPTS; + } + + static long baseCooldownMillis(int lockoutNumber) { + int idx = Math.min(Math.max(lockoutNumber - 1, 0), COOLDOWN_STEPS_MILLIS.length - 1); + return COOLDOWN_STEPS_MILLIS[idx]; + } + + public synchronized boolean recordLockout() { + if (isActive() || awaitingScreenClear) { + return false; + } + consecutiveLockouts++; + double jitter = MIN_JITTER + random.getAsDouble() * JITTER_RANGE; + long duration = Math.round(baseCooldownMillis(consecutiveLockouts) * jitter); + cooldownUntil = clock.getAsLong() + duration; + active = true; + dismissedOnce = false; + awaitingScreenClear = true; + return true; + } + + public synchronized void recordScreenCleared() { + awaitingScreenClear = false; + } + + public synchronized boolean isActive() { + if (active && clock.getAsLong() >= cooldownUntil) { + active = false; + } + return active; + } + + public synchronized long remainingMillis() { + return isActive() ? cooldownUntil - clock.getAsLong() : 0L; + } + + public synchronized int getConsecutiveLockouts() { + return consecutiveLockouts; + } + + public synchronized boolean tryClaimDismiss() { + long now = clock.getAsLong(); + if (dismissedOnce && now - lastDismissAt < DISMISS_INTERVAL_MILLIS) { + return false; + } + dismissedOnce = true; + lastDismissAt = now; + return true; + } + + public synchronized void recordSuccessfulLogin() { + consecutiveLockouts = 0; + active = false; + dismissedOnce = false; + awaitingScreenClear = 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 dbf9b1c3c19..da9f6b9ac83 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 @@ -21,6 +21,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Random; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -97,6 +98,7 @@ public static void markLoggedIn() { if (isLoggedIn()) { LOGIN_ATTEMPT_ACTIVE.set(false); lastLoginTimestamp.set(Instant.now()); + LoginLockout.getInstance().recordSuccessfulLogin(); } } @@ -125,7 +127,6 @@ public static boolean login() { log.warn("No active profile available for login"); return false; } - System.out.println(getActiveProfile()); Client client = Microbot.getClient(); if (client == null) { log.warn("Cannot login - client is not initialised"); @@ -195,6 +196,11 @@ public static boolean login(String username, String encryptedPassword, int world log.warn("Cannot login - client is not initialised"); return false; } + if (checkLoginLockout()) { + log.debug("Login skipped - login lockout cooldown active ({}s remaining)", + TimeUnit.MILLISECONDS.toSeconds(LoginLockout.getInstance().remainingMillis())); + return false; + } synchronized (LOGIN_LOCK) { Instant lastAttempt = LAST_LOGIN_ATTEMPT.get(); Instant now = Instant.now(); @@ -223,6 +229,38 @@ public static boolean login(String username, String encryptedPassword, int world } } + public static boolean isLoginLockoutScreen() { + Client client = Microbot.getClient(); + return client != null + && client.getGameState() == GameState.LOGIN_SCREEN + && LoginLockout.isLockoutLoginIndex(client.getLoginIndex()); + } + + public static boolean checkLoginLockout() { + LoginLockout lockout = LoginLockout.getInstance(); + if (!isLoginLockoutScreen()) { + lockout.recordScreenCleared(); + return lockout.isActive(); + } + if (lockout.recordLockout()) { + log.warn("Too many login attempts - pausing all logins for {}s (lockout #{})", + TimeUnit.MILLISECONDS.toSeconds(lockout.remainingMillis()), lockout.getConsecutiveLockouts()); + } + if (lockout.tryClaimDismiss()) { + dismissLoginLockoutScreen(Microbot.getClient()); + } + return true; + } + + private static void dismissLoginLockoutScreen(Client client) { + if (client == null || Microbot.getMouse() == null) { + return; + } + int loginScreenWidth = 804; + int startingWidth = (client.getCanvasWidth() / 2) - (loginScreenWidth / 2); + Microbot.getMouse().click(365 + startingWidth, 326); + } + private static void handleDisconnectDialogs(Client client) { if (client == null) { return; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index 08b7db440a2..2451dcf1172 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -1031,7 +1031,7 @@ private static Rs2WorldPoint getNearestWalkableTileForObjectInternal(GameObject WorldView topLevelWorldView = Microbot.getClient().getTopLevelWorldView(); // Check if player and object are on the same plane - if (playerLocation.getPlane() != tileObject.getWorldLocation().getPlane()) { + if (playerLocation.getWorldPoint() == null || playerLocation.getPlane() != tileObject.getWorldLocation().getPlane()) { return null; } 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 4c8d097a4a5..0d50ea21e69 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 @@ -1325,6 +1325,7 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance // sealed goal), collapsed to a 1-tile path, and the retry burned itself on it while the // unlearn arrived two lines later. withdrawWalkScopedDoorBlocks(); + Rs2WalkerTransports.withdrawWalkScopedTransportBlocks(); WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); if (playerLocWalk == null) { return WalkerState.MOVING; @@ -2818,11 +2819,11 @@ && walkFastCanvas(recoverTarget)) { break; } nextWalkingDistance = path.size() <= 5 ? 0 : Rs2Random.between(9, 12); - int dist2d = currentWorldPoint.distanceTo2D(Rs2Player.getWorldLocation()); + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) { exit = WalkExit.PLAYER_LOCATION_NULL; break; } + int dist2d = currentWorldPoint.distanceTo2D(playerLoc); boolean approachPlannedTransportOrigin = shouldApproachPlannedTransportOrigin( - hasExplicitTransportStep(path, i), - currentWorldPoint, - Rs2Player.getWorldLocation(), + hasExplicitTransportStep(path, i), currentWorldPoint, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE); if (dist2d > nextWalkingDistance || approachPlannedTransportOrigin) { tmarkPostTransport("post_transport_click_eligibility", target, @@ -2831,7 +2832,6 @@ && walkFastCanvas(recoverTarget)) { // Minimap clickable area is a circle, so reach is a Euclidean radius — // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). - WorldPoint playerLoc = Rs2Player.getWorldLocation(); final int MINIMAP_REACH_EUCLIDEAN = normalMinimapReach(); // Checkpoint-style walking: once we set a minimap flag, let the player actually @@ -2861,7 +2861,7 @@ && walkFastCanvas(recoverTarget)) { } final WorldPoint posBeforeWait = playerLoc; sleepUntil(() -> - interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= interimPreclickTiles() + isPlayerWithin2D(interimFinal, interimPreclickTiles()) || !Rs2Player.isMoving(), INTERIM_MOVING_POLL_MS); WorldPoint posAfterWait = Rs2Player.getWorldLocation(); @@ -3096,7 +3096,7 @@ && walkFastCanvas(recoverTarget)) { // Keep stuck-detection honest: observed movement resets the movement timer. // Without this, isStuckTooLong() fires after long successful walks because // routeState.lastMovedTimeMs is only refreshed at processWalk entry (not during the loop). - if (posBefore.distanceTo2D(Rs2Player.getWorldLocation()) > 0) { + if (hasPlayerMovedFrom2D(posBefore)) { routeState.lastMovedTimeMs = System.currentTimeMillis(); routeState.stuckCount = 0; } @@ -3186,11 +3186,11 @@ && walkFastCanvas(recoverTarget)) { } } - if (Rs2Tile.isTileReachable(finalTile) && Rs2Player.getWorldLocation().distanceTo(finalTile) >= finishTh) { + WorldPoint finalPlayerLoc = Rs2Player.getWorldLocation(); + if (finalPlayerLoc != null && Rs2Tile.isTileReachable(finalTile) && finalPlayerLoc.distanceTo(finalTile) >= finishTh) { final WorldPoint canvasClickWp = finalTile; - WorldPoint finalPlayerLoc = Rs2Player.getWorldLocation(); boolean finalClick; - if (rawPath != null && !rawPath.isEmpty() && finalPlayerLoc != null) { + if (rawPath != null && !rawPath.isEmpty()) { int rawAnchorIndex = rawAnchorIndexForPathPosition(rawPath, path, finalPlayerLoc); finalClick = clickRouteBackedShortWalk(rawPath, canvasClickWp, finalPlayerLoc, normalMinimapReach() - 1, rawAnchorIndex); @@ -3214,9 +3214,10 @@ && walkFastCanvas(recoverTarget)) { && Rs2Player.isMoving()) { exit = WalkExit.ROUTE_MOVE_IN_FLIGHT; } - WorldPoint pathLastForFinish = path.get(path.size() - 1); - int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); - int finalDist = Rs2Player.getWorldLocation().distanceTo(target); + int finishThreshold = tightFinishThreshold(target, path.get(path.size() - 1), distance); + WorldPoint finishPlayerLoc = Rs2Player.getWorldLocation(); + if (finishPlayerLoc == null) { return WalkerState.MOVING; } + int finalDist = finishPlayerLoc.distanceTo(target); if (finalDist <= finishThreshold) { setTarget(null, "rs2walker:processWalk:arrived-within-distance"); return WalkerState.ARRIVED; @@ -3315,7 +3316,7 @@ && walkFastCanvas(recoverTarget)) { } walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", processWalkTail + 1, exit.wireName(offPathDeferDetail), - Rs2Player.getWorldLocation().distanceTo(target), partialPath); + finalDist, partialPath); continue; } } catch (Exception ex) { @@ -3900,7 +3901,6 @@ public static void walkFastLocal(LocalPoint localPoint) { Microbot.doInvoke(entry, new Rectangle(1, 1, Microbot.getClient().getCanvasWidth(), Microbot.getClient().getCanvasHeight())); - //Rs2Reflection.invokeMenu(canvasX, canvasY, MenuAction.WALK.getId(), 0, -1, "Walk here", "", -1, -1); } public static boolean walkFastCanvas(WorldPoint worldPoint) { @@ -4443,7 +4443,7 @@ private static ObstacleResolution resolveRecoveryObstacle(List rawPa && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH_MAX_DISTANCE)) { WebWalkLog.spInfo("recovery_on_origin_transport | origin={} player={} rawIdx={}", compactWorldPoint(rawPath.get(ri)), compactWorldPoint(playerLoc), ri); - if (handleTransports(rawPath, ri)) { + if (Rs2WalkerTransports.dispatchRecoveryTransport(rawPath, ri)) { return ObstacleResolution.interacted(); } } @@ -6830,6 +6830,16 @@ public static boolean isNear(WorldPoint target) { return isNear(target, Rs2Player.getWorldLocation()); } + private static boolean isPlayerWithin2D(WorldPoint from, int tiles) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return playerLoc != null && from.distanceTo2D(playerLoc) <= tiles; + } + + private static boolean hasPlayerMovedFrom2D(WorldPoint from) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + return playerLoc != null && from.distanceTo2D(playerLoc) > 0; + } + /** Snapshot variant (B2): the walk loop passes its pass-start position instead of re-reading. */ private static boolean isNear(WorldPoint target, WorldPoint playerLoc) { return playerLoc != null && playerLoc.equals(target); @@ -7099,7 +7109,7 @@ private static boolean interactingActorNearWalkablePath() { if (actor == null) { return false; } - WorldPoint loc = actor.getWorldLocation(); + WorldPoint loc = Microbot.getClientThread().runOnClientThreadOptional(actor::getWorldLocation).orElse(null); if (loc == null) { return false; } @@ -7199,6 +7209,7 @@ public static WorldPoint nearestReachable(WorldPoint start, Collection DISPATCH_TRACE = new ThreadLocal<>(); + + static DispatchResult dispatchSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection) { + TransportDispatchTrace previous = DISPATCH_TRACE.get(); + TransportDispatchTrace trace = new TransportDispatchTrace(); + DISPATCH_TRACE.set(trace); + boolean handled; + try { + handled = handleSelectedTransport(path, indexOfStartPoint, selection); + } finally { + if (previous != null) { + DISPATCH_TRACE.set(previous); + } else { + DISPATCH_TRACE.remove(); + } + } + Transport transport = selection != null ? selection.getLocalExecutionTransport() : null; + WorldPoint origin = transport != null ? transport.getOrigin() : null; + WorldPoint destination = transport != null ? transport.getDestination() : null; + TransportDispatchTrace.Verdict verdict = + trace.verdict(handled, Rs2Player.getWorldLocation(), origin, destination); + boolean struckOut = false; + if (verdict == TransportDispatchTrace.Verdict.HANDLED) { + transportRefusalLedger.clear(origin, destination); + } else if (verdict == TransportDispatchTrace.Verdict.REFUSED_AFTER_INTERACTION) { + struckOut = registerTransportRefusal(origin, destination, "interaction-refused"); + } + return new DispatchResult(handled, verdict, trace, struckOut); + } + + static boolean dispatchRecoveryTransport(List path, int index) { + Optional selection = + Rs2PathApi.getActiveTransportSelection(path, index); + if (selection.isEmpty()) { + return false; + } + DispatchResult result = dispatchSelectedTransport(path, index, selection.get(), false); + if (result.handled || result.struckOut) { + return true; + } + if (result.verdict != TransportDispatchTrace.Verdict.REFUSED_WITHOUT_INTERACTION) { + return false; + } + Transport transport = selection.get().getLocalExecutionTransport(); + return registerTransportFallThrough(transport.getOrigin(), transport.getDestination(), + result.trace.reason()); + } + + private static void noteTransportInteraction() { + TransportDispatchTrace trace = DISPATCH_TRACE.get(); + if (trace != null) { + trace.interacted(); + } + } + + private static void noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip reason) { + TransportDispatchTrace trace = DISPATCH_TRACE.get(); + if (trace != null) { + trace.skip(reason); + } + } + + private static void noteRefusal(TransportDispatchTrace.Refusal reason) { + TransportDispatchTrace trace = DISPATCH_TRACE.get(); + if (trace != null) { + trace.refuse(reason); + } + } + /** * Executes the exact transport retained by the active route through its registered Microbot executor. * Candidate discovery must happen through immutable route steps, never by rescanning the mutable @@ -161,6 +249,7 @@ static boolean handleSelectedTransport(List path, int indexOfStartPoint, Rs2PathApi.ActiveTransportSelection selection) { if (selection == null || !selection.isExecutable()) { + noteRefusal(TransportDispatchTrace.Refusal.UNSUPPORTED); if (selection != null) { WebWalkLog.spWarn("selected transport has no executor | type={} origin={} dest={}", selection.getEdge().getType(), @@ -177,6 +266,7 @@ static boolean handleSelectedTransport(List path, } if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.STATIONARY_DOOR_SETTLING); return false; } if (log.isDebugEnabled()) { @@ -216,6 +306,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.PLANE_MISMATCH); continue; } @@ -230,12 +321,14 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", transport.getDisplayInfo(), OFFSET, transport.getDestination()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); continue; } } if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); continue; } } @@ -258,6 +351,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i transport.getDisplayInfo(), compactWorldPoint(transport.getOrigin()), compactWorldPoint(transport.getDestination())); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); continue; } @@ -287,6 +381,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.PLANE_MISMATCH); break; // plane won't change across iterations, so break instead of continue } @@ -302,6 +397,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (digOrigin == null || playerAtMound == null || !playerAtMound.equals(digOrigin)) { // Digging is tile-sensitive. Let the ordinary path click finish the // approach instead of firing the spade from an adjacent mound tile. + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.BARROWS_APPROACH); return false; } boolean dug = attemptObserved(transport, @@ -331,6 +427,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i "selected terminal travel has no supported interaction mode | type={} origin={} dest={}", transport.getType(), compactWorldPoint(transport.getOrigin()), compactWorldPoint(transport.getDestination())); + noteRefusal(TransportDispatchTrace.Refusal.UNSUPPORTED); break originLoop; } @@ -373,6 +470,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (!markTerminalTravelAttempt(transport)) { log.debug("[Walker] terminal travel edge already attempted this walk: {}", transport.getDisplayInfo()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.TERMINAL_ALREADY_ATTEMPTED); break originLoop; } if (!npcAction.equalsIgnoreCase(transport.getAction())) { @@ -448,11 +546,13 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i transport.getName(), transport.getAction(), compactWorldPoint(transport.getDestination()), transport.getDisplayInfo()); + noteRefusal(TransportDispatchTrace.Refusal.ACTION_MISSING); break originLoop; } if (!markTerminalTravelAttempt(transport)) { log.debug("[Walker] terminal travel edge already attempted this walk: {}", transport.getDisplayInfo()); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.TERMINAL_ALREADY_ATTEMPTED); break originLoop; } prepareTransportObjectForInteraction(terminalObject); @@ -471,6 +571,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } } else { WorldPoint originTile = path.get(i); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.TERMINAL_APPROACH); boolean clicked = Rs2Walker.walkFastCanvas(originTile); if (!clicked) { WorldPoint playerLoc = Rs2Player.getWorldLocation(); @@ -569,6 +670,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (transport.getType() == TransportType.SPIRIT_TREE) { if (!Rs2PathApi.isSpiritTreeTravelEnabled()) { log.debug("[Walker] skip spirit tree transport — setting is off"); + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.SPIRIT_TREE_TRAVEL_DISABLED); continue; } if (attemptObserved(transport, () -> handleSpiritTree(transport))) { @@ -637,6 +739,9 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i WorldPoint plFairy = Rs2Player.getWorldLocation(); WorldPoint tdFairy = transport.getDestination(); boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); + if (alreadyAtFairyDest) { + noteDeliberateSkip(TransportDispatchTrace.DeliberateSkip.ALREADY_AT_DESTINATION); + } if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); @@ -767,6 +872,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i if (!(object instanceof GroundObject) && !MagicMushtree.isMagicMushtree(transport.getObjectId())) { if (requiresReachableObjectOrigin(Boolean.TRUE.equals(SERVER_APPROACH_ALLOWED.get())) && !Rs2Tile.isTileReachable(transport.getOrigin())) { + noteRefusal(TransportDispatchTrace.Refusal.ORIGIN_UNREACHABLE); break; } } @@ -784,7 +890,9 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i log.info("[Walker] Closed transport variant at {} (id={} name={}) — opening before {}", transport.getOrigin(), object.getId(), comp.getName(), transportAction); final int closedId = object.getId(); - Rs2GameObject.interact(object, "Open"); + if (Rs2GameObject.interact(object, "Open")) { + noteTransportInteraction(); + } Rs2Player.waitForAnimation(2000); TileObject reopened = Rs2GameObject.getAll(o -> { if (o.getId() == closedId) return false; @@ -798,8 +906,11 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } } - String interactionAction = resolveTransportObjectAction(object, transportActions) - .orElse(transportAction); + Optional resolvedAction = resolveTransportObjectAction(object, transportActions); + if (resolvedAction.isEmpty()) { + noteRefusal(TransportDispatchTrace.Refusal.ACTION_MISSING); + } + String interactionAction = resolvedAction.orElse(transportAction); if (!Objects.equals(interactionAction, transportAction)) { log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", interactionAction, transportAction, object.getWorldLocation(), object.getId()); @@ -843,12 +954,70 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } return false; } + noteRefusal(TransportDispatchTrace.Refusal.OBJECT_MISSING); } } } return false; } + private static final TransportRefusalLedger transportRefusalLedger = new TransportRefusalLedger(); + + static int transportRefusalStrikes(WorldPoint origin, WorldPoint destination) { + return transportRefusalLedger.strikes(origin, destination); + } + + static boolean registerTransportRefusal(WorldPoint origin, WorldPoint destination, String mode) { + if (origin == null || destination == null) { + return false; + } + int strikes = transportRefusalLedger.registerRefusal(origin, destination); + if (!TransportRefusalLedger.isStrikeOut(strikes)) { + WebWalkLog.spInfo("transport_refused | origin={} dest={} mode={} strike={}/{}", + compactWorldPoint(origin), compactWorldPoint(destination), mode, + strikes, TransportRefusalLedger.STRIKE_LIMIT); + return false; + } + strikeOutTransport(origin, destination, mode, TransportRefusalLedger.STRIKE_LIMIT); + return true; + } + + static boolean registerTransportFallThrough(WorldPoint origin, WorldPoint destination, String reason) { + if (origin == null || destination == null) { + return false; + } + int fallThroughs = transportRefusalLedger.registerFallThrough(origin, destination); + if (!TransportRefusalLedger.isFallThroughStrikeOut(fallThroughs)) { + WebWalkLog.spInfo("transport_fall_through | origin={} dest={} reason={} strike={}/{}", + compactWorldPoint(origin), compactWorldPoint(destination), reason, + fallThroughs, TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT); + return false; + } + strikeOutTransport(origin, destination, "fall-through " + reason, + TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT); + return true; + } + + static int transportFallThroughs(WorldPoint origin, WorldPoint destination) { + return transportRefusalLedger.fallThroughs(origin, destination); + } + + private static void strikeOutTransport(WorldPoint origin, WorldPoint destination, String mode, int limit) { + if (Rs2PathApi.learnBlockedEdge(origin, destination, "transport-refused (" + mode + ")")) { + transportRefusalLedger.recordWalkScopedBlock(origin, destination); + } + WebWalkLog.spInfo("transport_strike_out | origin={} dest={} mode={} — {} refusals; " + + "blocking transport for this walk and replanning", + compactWorldPoint(origin), compactWorldPoint(destination), mode, limit); + recalculatePath(); + } + + static void withdrawWalkScopedTransportBlocks() { + for (WorldPoint[] edge : transportRefusalLedger.drainWalkScopedBlocks()) { + Rs2PathApi.unlearnBlockedEdge(edge[0], edge[1], "walk-scoped transport strike-out expired"); + } + } + private static boolean waitForPostHandleObjectLanding(Transport transport, WorldPoint destWait, int maxInclusive) { @@ -1036,8 +1205,10 @@ private static boolean handleObject(Transport transport, TileObject tileObject, ensureRequiredItemBeforeTransport(transport); WorldPoint before = Rs2Player.getWorldLocation(); if (!Rs2GameObject.interact(tileObject, action)) { + noteRefusal(TransportDispatchTrace.Refusal.INTERACT_FAILED); return false; } + noteTransportInteraction(); // Unlike the other exception handlers, a toll-gate interaction is not complete merely // because the menu action was issued: it may first server-walk from several tiles away and // then present a confirmation dialogue. Bubble an unobserved crossing back to the caller so @@ -1149,10 +1320,17 @@ static boolean handleSelectedTransport(List path, int indexOfStartPoint, Rs2PathApi.ActiveTransportSelection selection, boolean allowServerApproach) { + return dispatchSelectedTransport(path, indexOfStartPoint, selection, allowServerApproach).handled; + } + + static DispatchResult dispatchSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection, + boolean allowServerApproach) { Boolean previous = SERVER_APPROACH_ALLOWED.get(); SERVER_APPROACH_ALLOWED.set(allowServerApproach); try { - return handleSelectedTransport(path, indexOfStartPoint, selection); + return dispatchSelectedTransport(path, indexOfStartPoint, selection); } finally { if (Boolean.TRUE.equals(previous)) { SERVER_APPROACH_ALLOWED.set(Boolean.TRUE); @@ -2173,6 +2351,14 @@ private static boolean attemptObserved(Transport transport, BooleanSupplier acti recordTransportAttempt(transport); } boolean ok = action.getAsBoolean(); + if (ok) + { + noteTransportInteraction(); + } + else + { + noteRefusal(TransportDispatchTrace.Refusal.HANDLER_FAILED); + } if (leaguesActive) { recordTransportResult(transport, ok); @@ -2193,6 +2379,14 @@ private static boolean attemptObservedWithoutAttemptRecord(Transport transport, } boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); boolean ok = action.getAsBoolean(); + if (ok) + { + noteTransportInteraction(); + } + else + { + noteRefusal(TransportDispatchTrace.Refusal.HANDLER_FAILED); + } if (leaguesActive) { recordTransportResult(transport, ok); @@ -2317,6 +2511,7 @@ private static boolean handleSpiritTree(Transport transport) { if (!interactResult) { return false; } + noteTransportInteraction(); } boolean result = interactWithAdventureLog(transport); @@ -2736,6 +2931,7 @@ private static boolean handleQuetzal(Transport transport) { Rs2NpcModel renu = Rs2Npc.getNpc(NpcID.QUETZAL_CHILD_GREEN); if (Rs2Tile.isTileReachable(transport.getOrigin()) && Rs2Npc.interact(renu, "travel")) { + noteTransportInteraction(); Rs2Player.waitForWalking(); WorldPoint dest = transport.getDestination(); String mapLabel = resolveQuetzalMapOptionLabel(transport); @@ -2787,6 +2983,7 @@ private static boolean handleCharterShip(Transport transport) { Rs2NpcModel npc = Rs2Npc.getNpc(npcName); log.info("Charter Ship NPC: " + npcName + " - " + (npc != null ? npc.getId() : "not found")); if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { + noteTransportInteraction(); Rs2Player.waitForWalking(); if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(885, 4), 5000)) { return false; @@ -3051,6 +3248,7 @@ private static boolean handleGlider(Transport transport) { // Interact with the gnome glider NPC if (Rs2Npc.interact(gnome, action)) { + noteTransportInteraction(); sleepUntil(() -> !Rs2Widget.isHidden(GLIDER_PARENT_WIDGET, GLIDER_CHILD_WIDGET)); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java new file mode 100644 index 00000000000..a0a1d3f1a1e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportDispatchTrace.java @@ -0,0 +1,95 @@ +package net.runelite.client.plugins.microbot.util.walker.transport; + +import net.runelite.api.coords.WorldPoint; + +public final class TransportDispatchTrace { + + public enum Verdict { + HANDLED, + REFUSED_AFTER_INTERACTION, + INTERACTION_LEFT_ORIGIN, + DELIBERATE_SKIP, + REFUSED_WITHOUT_INTERACTION, + NOT_ATTEMPTED + } + + public enum DeliberateSkip { + STATIONARY_DOOR_SETTLING, + ALREADY_AT_DESTINATION, + SPIRIT_TREE_TRAVEL_DISABLED, + PLANE_MISMATCH, + BARROWS_APPROACH, + TERMINAL_ALREADY_ATTEMPTED, + TERMINAL_APPROACH + } + + public enum Refusal { + ORIGIN_UNREACHABLE, + ACTION_MISSING, + OBJECT_MISSING, + INTERACT_FAILED, + HANDLER_FAILED, + UNSUPPORTED + } + + private boolean interacted; + private DeliberateSkip deliberateSkip; + private Refusal refusal; + + public void interacted() { + interacted = true; + } + + public void skip(DeliberateSkip reason) { + if (deliberateSkip == null) { + deliberateSkip = reason; + } + } + + public void refuse(Refusal reason) { + if (refusal == null) { + refusal = reason; + } + } + + public boolean hasInteracted() { + return interacted; + } + + public DeliberateSkip deliberateSkip() { + return deliberateSkip; + } + + public Refusal refusal() { + return refusal; + } + + public Verdict verdict(boolean handled, WorldPoint player, WorldPoint origin, WorldPoint destination) { + return classify(handled, interacted, deliberateSkip, refusal, + TransportRefusalLedger.isStillAtOrigin(player, origin, destination)); + } + + public static Verdict classify(boolean handled, boolean interacted, DeliberateSkip deliberateSkip, + Refusal refusal, boolean stillAtOrigin) { + if (handled) { + return Verdict.HANDLED; + } + if (interacted) { + return stillAtOrigin ? Verdict.REFUSED_AFTER_INTERACTION : Verdict.INTERACTION_LEFT_ORIGIN; + } + if (deliberateSkip != null) { + return Verdict.DELIBERATE_SKIP; + } + if (refusal != null) { + return Verdict.REFUSED_WITHOUT_INTERACTION; + } + return Verdict.NOT_ATTEMPTED; + } + + public String reason() { + if (deliberateSkip != null) { + return deliberateSkip.name(); + } + return refusal != null ? refusal.name() : ""; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java new file mode 100644 index 00000000000..0b629c620fe --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/TransportRefusalLedger.java @@ -0,0 +1,92 @@ +package net.runelite.client.plugins.microbot.util.walker.transport; + +import net.runelite.api.coords.WorldPoint; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +public final class TransportRefusalLedger { + + public static final int STRIKE_LIMIT = 2; + public static final int FALL_THROUGH_STRIKE_LIMIT = 3; + + private final Map, Integer> strikes = new ConcurrentHashMap<>(); + private final Map, Integer> fallThroughs = new ConcurrentHashMap<>(); + private final ConcurrentLinkedQueue walkScopedBlocks = new ConcurrentLinkedQueue<>(); + + public int registerRefusal(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return strikes.merge(List.of(origin, destination), 1, Integer::sum); + } + + public int registerFallThrough(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return fallThroughs.merge(List.of(origin, destination), 1, Integer::sum); + } + + public static boolean isStrikeOut(int strikeCount) { + return strikeCount == STRIKE_LIMIT; + } + + public static boolean isFallThroughStrikeOut(int fallThroughCount) { + return fallThroughCount == FALL_THROUGH_STRIKE_LIMIT; + } + + public static boolean isStillAtOrigin(WorldPoint player, WorldPoint origin, WorldPoint destination) { + if (player == null || origin == null || destination == null || player.getPlane() != origin.getPlane()) { + return false; + } + int toOrigin = player.distanceTo2D(origin); + if (toOrigin > 1) { + return false; + } + return player.getPlane() != destination.getPlane() || toOrigin < player.distanceTo2D(destination); + } + + public int strikes(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return strikes.getOrDefault(List.of(origin, destination), 0); + } + + public int fallThroughs(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return 0; + } + return fallThroughs.getOrDefault(List.of(origin, destination), 0); + } + + public void clear(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return; + } + strikes.remove(List.of(origin, destination)); + fallThroughs.remove(List.of(origin, destination)); + } + + public void recordWalkScopedBlock(WorldPoint origin, WorldPoint destination) { + if (origin == null || destination == null) { + return; + } + walkScopedBlocks.add(new WorldPoint[]{origin, destination}); + } + + public List drainWalkScopedBlocks() { + strikes.clear(); + fallThroughs.clear(); + List drained = new ArrayList<>(); + WorldPoint[] edge; + while ((edge = walkScopedBlocks.poll()) != null) { + drained.add(edge); + } + return drained; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java index 1c84b4b7e5c..6a9372abb9d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/widget/Rs2Widget.java @@ -44,6 +44,7 @@ public static boolean clickWidget(String text, Optional widgetId, int c widget = findWidget(text, null, exact); } else { Widget rootWidget = getWidget(widgetId.get(), childId); + if (rootWidget == null) return false; List rootWidgets = new ArrayList<>(); rootWidgets.add(rootWidget); widget = findWidget(text, rootWidgets, exact); @@ -234,6 +235,7 @@ public static Widget findWidget(String text, List children, boolean exac * @return The widget containing the specified text, or null if no match is found. */ public static Widget searchChildren(String text, Widget child, boolean exact) { + if (child == null) return null; if (matchesText(child, text, exact)) return child; Widget[][] childGroups = {child.getChildren(), child.getNestedChildren(), child.getDynamicChildren(), child.getStaticChildren()}; @@ -325,6 +327,7 @@ public static Widget findWidget(int spriteId, List children) { * @return The widget with the specified sprite ID, or null if not found. */ public static Widget searchChildren(int spriteId, Widget child) { + if (child == null) return null; if (matchesSpriteId(child, spriteId)) return child; Widget[][] childGroups = {child.getChildren(), child.getNestedChildren(), child.getDynamicChildren(), child.getStaticChildren()}; @@ -483,7 +486,9 @@ public static Map getWidgetsKeyMap(int widgetGroupId, int widge private static Integer getProcessingWidgetKeyCode(String actionText) { log.debug("Searching for processing widget with action text: {}", actionText); - Widget optionWidget = findWidget(actionText, List.of(getWidget(InterfaceID.SKILLMULTI, 0)), false); + Widget mainWidget = getWidget(InterfaceID.SKILLMULTI, 0); + if (mainWidget == null) return null; + Widget optionWidget = findWidget(actionText, List.of(mainWidget), false); if (optionWidget == null) return null; return getProcessingWidgetKeyCode(optionWidget); } 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 f95e173bc7d..e076effb721 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 @@ -133,19 +133,18 @@ private static int getPorfileMemberShipDays() { try { ConfigProfile activeProfile = LoginManager.getActiveProfile(); - String profileName = activeProfile != null ? activeProfile.getName() : null; long memberExpireDays = activeProfile != null ? activeProfile.getMemberExpireDays() : 0; long memberExpireDaysTimeStemp = activeProfile != null ? activeProfile.getMemberExpireDaysTimeStemp() : 0; if( memberExpireDays == 0 && memberExpireDaysTimeStemp == 0){ - log.warn("No membership expiry data set in profile: {} (memberExpireDays: {}, memberExpireDaysTimeStemp: {})", - profileName, memberExpireDays, memberExpireDaysTimeStemp); + log.warn("No membership expiry data set in profile (memberExpireDays: {}, memberExpireDaysTimeStemp: {})", + memberExpireDays, memberExpireDaysTimeStemp); return -1; } // calculate days since cache was saved long currentTime = System.currentTimeMillis(); - log.info("Checking membership expiry for profile: {} (memberExpireDays: {}, memberExpireDaysTimeStemp: {}, currentTime: {})", - profileName, memberExpireDays, memberExpireDaysTimeStemp, currentTime); + log.info("Checking membership expiry for profile (memberExpireDays: {}, memberExpireDaysTimeStemp: {}, currentTime: {})", + memberExpireDays, memberExpireDaysTimeStemp, currentTime); long daysSinceCached = (currentTime - memberExpireDaysTimeStemp) / (24 * 60 * 60 * 1000); if( daysSinceCached > memberExpireDays){ diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java index 32c247edcdb..d2818e3edf0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/CalculatorType.java @@ -24,6 +24,7 @@ */ package net.runelite.client.plugins.skillcalculator; +import java.util.Arrays; import javax.annotation.Nullable; import lombok.AllArgsConstructor; import lombok.Getter; @@ -44,7 +45,7 @@ enum CalculatorType PRAYER(Skill.PRAYER, PrayerBonus.values(), PrayerAction.values()), CRAFTING(Skill.CRAFTING, null, CraftingAction.values()), FIREMAKING(Skill.FIREMAKING, FiremakingBonus.values(), FiremakingAction.values()), - MAGIC(Skill.MAGIC, null, MagicAction.values()), + MAGIC(Skill.MAGIC, null, Arrays.stream(MagicAction.values()).filter(action -> action.getXp() > 0).toArray(SkillAction[]::new)), FLETCHING(Skill.FLETCHING, null, FletchingAction.values()), WOODCUTTING(Skill.WOODCUTTING, WoodcuttingBonus.values(), WoodcuttingAction.values()), RUNECRAFT(Skill.RUNECRAFT, RunecraftBonus.values(), RunecraftAction.values()), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java index d5081ef8d71..94fc1a3f205 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/skillcalculator/skills/MagicAction.java @@ -27,12 +27,12 @@ import lombok.AllArgsConstructor; import lombok.Getter; import net.runelite.api.SpriteID; +import net.runelite.api.widgets.Widget; import net.runelite.client.game.ItemManager; import net.runelite.client.plugins.microbot.util.magic.Rs2Spellbook; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import java.util.Arrays; -import java.util.stream.Collectors; @AllArgsConstructor @Getter @@ -259,10 +259,30 @@ public boolean isMembers(final ItemManager itemManager) } public int getWidgetId() { - return Rs2Widget.findWidget(name, Arrays.stream(Rs2Widget.getWidget(218, 0).getStaticChildren()).collect(Collectors.toList())).getId(); + Widget widget = findSpellWidget(); + return widget == null ? -1 : widget.getId(); } public String[] getActions() { - return Rs2Widget.findWidget(name, Arrays.stream(Rs2Widget.getWidget(218, 0).getStaticChildren()).collect(Collectors.toList())).getActions(); + Widget widget = findSpellWidget(); + return widget == null ? null : widget.getActions(); + } + + private Widget findSpellWidget() { + Widget spellbook = Rs2Widget.getWidget(218, 0); + if (spellbook != null && spellbook.getStaticChildren() != null) { + Widget byName = Rs2Widget.findWidget(name, Arrays.asList(spellbook.getStaticChildren())); + if (byName != null) { + return byName; + } + } + Widget spellList = Rs2Widget.getWidget(218, 3); + if (spellList == null || spellList.getStaticChildren() == null) { + return null; + } + return Arrays.stream(spellList.getStaticChildren()) + .filter(child -> child != null && child.getSpriteId() == sprite) + .findFirst() + .orElse(null); } } diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv index cb95816d09c..e70121326da 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv @@ -1156,8 +1156,8 @@ 3026 3247 1 3025 3248 0 Climb-down;Staircase;9584 3025 3248 0 3026 3247 1 Climb-up;Staircase;9582 3025 3249 0 3026 3247 1 Climb-up;Staircase;9582 -3018 3231 0 2962 9650 0 Climb-down;Manhole;10321 -2962 9650 0 3018 3233 0 Climb-up;Ladder;10309 +3018 3231 0 2962 9650 0 Climb-down;Manhole;10321 Y +2962 9650 0 3018 3233 0 Climb-up;Ladder;10309 Y # Wizard Tower 3105 3162 0 3104 9576 0 Climb-down;Ladder;2147 diff --git a/runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java b/runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java new file mode 100644 index 00000000000..7c473e26ec0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/callback/ClientThreadTimeoutLoggingTest.java @@ -0,0 +1,101 @@ +package net.runelite.client.callback; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Field; +import java.util.Optional; +import java.util.stream.Collectors; +import net.runelite.api.Client; +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.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ClientThreadTimeoutLoggingTest +{ + private ClientThread clientThread; + private Logger logger; + private ListAppender appender; + private volatile Thread gameThread; + + @Before + public void setUp() throws Exception + { + Client client = mock(Client.class); + when(client.isClientThread()).thenAnswer(i -> Thread.currentThread() == gameThread); + clientThread = new ClientThread(); + Field field = ClientThread.class.getDeclaredField("client"); + field.setAccessible(true); + field.set(clientThread, client); + clientThread.clientThreadTimeoutMillis = 20; + + logger = (Logger) LoggerFactory.getLogger(ClientThread.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @After + public void tearDown() + { + logger.detachAppender(appender); + } + + @Test + public void repeatedTimeoutsLogOneWarningWithoutStackAndNoError() + { + for (int i = 0; i < 5; i++) + { + Optional result = clientThread.runOnClientThreadOptional(() -> 1); + assertFalse(result.isPresent()); + } + + assertEquals(0, count(Level.ERROR)); + assertEquals(1, count(Level.WARN)); + ILoggingEvent warning = appender.list.stream().filter(e -> e.getLevel() == Level.WARN).findFirst().get(); + assertNull(warning.getThrowableProxy()); + } + + @Test + public void taskFailureStillLogsError() throws Exception + { + clientThread.clientThreadTimeoutMillis = 10000; + gameThread = new Thread(() -> + { + while (!Thread.currentThread().isInterrupted()) + { + clientThread.invoke(); + Thread.onSpinWait(); + } + }); + gameThread.start(); + try + { + Optional result = clientThread.runOnClientThreadOptional(() -> + { + throw new IllegalStateException("boom"); + }); + assertFalse(result.isPresent()); + } + finally + { + gameThread.interrupt(); + gameThread.join(1000); + } + + assertEquals(1, count(Level.ERROR)); + assertEquals(0, count(Level.WARN)); + } + + private long count(Level level) + { + return appender.list.stream().filter(e -> e.getLevel() == level).collect(Collectors.counting()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java b/runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java new file mode 100644 index 00000000000..df2951f297e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/config/ConfigProfileTest.java @@ -0,0 +1,29 @@ +package net.runelite.client.config; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class ConfigProfileTest +{ + @Test + public void toStringExcludesCredentials() + { + ConfigProfile profile = new ConfigProfile(42L); + profile.setName("login-name-marker"); + profile.setPassword("password-marker"); + profile.setBankPin("9182"); + profile.setDiscordWebhookUrl("https://discord.test/api/webhooks/webhook-marker"); + + String text = profile.toString(); + + assertTrue(text.contains("id=42")); + assertFalse(text.contains("login-name-marker")); + assertFalse(text.contains("password-marker")); + assertFalse(text.contains("9182")); + assertFalse(text.contains("webhook-marker")); + assertFalse(text.contains("password=")); + assertFalse(text.contains("bankPin=")); + assertFalse(text.contains("discordWebhookUrl=")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java new file mode 100644 index 00000000000..9d3d86317bf --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/kourendlibrary/KourendLibraryPluginTest.java @@ -0,0 +1,116 @@ +package net.runelite.client.plugins.kourendlibrary; + +import com.google.inject.Guice; +import com.google.inject.testing.fieldbinder.Bind; +import com.google.inject.testing.fieldbinder.BoundFieldModule; +import javax.inject.Inject; +import javax.swing.SwingUtilities; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.events.ConfigChanged; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.overlay.OverlayManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class KourendLibraryPluginTest +{ + @Inject + KourendLibraryPlugin plugin; + + @Mock + @Bind + Client client; + + @Mock + @Bind + ClientThread clientThread; + + @Mock + @Bind + ClientToolbar clientToolbar; + + @Mock + @Bind + Library library; + + @Mock + @Bind + OverlayManager overlayManager; + + @Mock + @Bind + KourendLibraryOverlay overlay; + + @Mock + @Bind + KourendLibraryTutorialOverlay tutorialOverlay; + + @Mock + @Bind + KourendLibraryConfig config; + + @Mock + @Bind + ItemManager itemManager; + + @Before + public void before() + { + Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this); + } + + @Test + public void hideButtonReadsPlayerLocationOnClientThread() throws Exception + { + Player player = mock(Player.class); + when(player.getWorldLocation()).thenReturn(new WorldPoint(1632, 3807, 0)); + when(client.getLocalPlayer()).thenReturn(player); + when(config.hideButton()).thenReturn(true); + + ConfigChanged event = new ConfigChanged(); + event.setGroup(KourendLibraryConfig.GROUP_KEY); + event.setKey("hideButton"); + plugin.onConfigChanged(event); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(clientThread).invokeLater(task.capture()); + SwingUtilities.invokeAndWait(() -> { }); + verify(client, never()).getLocalPlayer(); + + task.getValue().run(); + SwingUtilities.invokeAndWait(() -> { }); + verify(clientToolbar).addNavigation(any()); + } + + @Test + public void showTargetHintArrowReadsPlayerLocationOnClientThread() + { + when(client.getLocalPlayer()).thenReturn(null); + + ConfigChanged event = new ConfigChanged(); + event.setGroup(KourendLibraryConfig.GROUP_KEY); + event.setKey("showTargetHintArrow"); + plugin.onConfigChanged(event); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(clientThread).invokeLater(task.capture()); + verify(client, never()).getLocalPlayer(); + + task.getValue().run(); + verify(client).getLocalPlayer(); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java new file mode 100644 index 00000000000..a6d7c47f13e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotLogInterruptTest.java @@ -0,0 +1,65 @@ +package net.runelite.client.plugins.microbot; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +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; + +public class MicrobotLogInterruptTest +{ + private Logger logger; + private ListAppender appender; + private Level previousLevel; + + @Before + public void setUp() + { + logger = (Logger) LoggerFactory.getLogger(Microbot.class); + previousLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + @After + public void tearDown() + { + logger.detachAppender(appender); + logger.setLevel(previousLevel); + } + + @Test + public void interruptedClientThreadWaitIsNotLoggedAsError() + { + Microbot.logStackTrace("AttackNpcScript", new RuntimeException("Interrupted waiting for client thread", new InterruptedException())); + + assertEquals(1, appender.list.size()); + assertEquals(Level.DEBUG, appender.list.get(0).getLevel()); + } + + @Test + public void otherFailuresStillLogError() + { + Microbot.logStackTrace("AttackNpcScript", new IllegalStateException("boom")); + + assertEquals(1, appender.list.size()); + assertEquals(Level.ERROR, appender.list.get(0).getLevel()); + } + + @Test + public void cyclicCauseChainTerminates() + { + IllegalStateException a = new IllegalStateException("a"); + IllegalStateException b = new IllegalStateException("b", a); + a.initCause(b); + + assertFalse(Microbot.isInterruption(a)); + } +} 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 new file mode 100644 index 00000000000..924e6d5976d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/MicrobotWorldHopTest.java @@ -0,0 +1,114 @@ +package net.runelite.client.plugins.microbot; + +import net.runelite.api.Client; +import net.runelite.api.World; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; +import java.util.concurrent.Callable; +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.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +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 MicrobotWorldHopTest { + + private MockedStatic microbot; + private Client client; + private World target; + private final AtomicInteger clientThreadCalls = new AtomicInteger(); + private final AtomicBoolean switcherLoaded = new AtomicBoolean(); + private final AtomicInteger openCall = new AtomicInteger(-1); + private final AtomicInteger hopCall = new AtomicInteger(-1); + + @Before + public void setUp() { + client = mock(Client.class); + target = mock(World.class); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + clientThreadCalls.incrementAndGet(); + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + Widget buttons = mock(Widget.class); + when(client.getWidget(InterfaceID.Worldswitcher.BUTTONS)).thenAnswer(inv -> switcherLoaded.get() ? buttons : null); + doAnswer(inv -> { + openCall.set(clientThreadCalls.get()); + return null; + }).when(client).openWorldHopper(); + doAnswer(inv -> { + hopCall.set(clientThreadCalls.get()); + return null; + }).when(client).hopToWorld(any()); + + microbot = Mockito.mockStatic(Microbot.class, Mockito.CALLS_REAL_METHODS); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + } + + @After + public void tearDown() { + microbot.close(); + } + + @Test + public void opensSwitcherThenHopsOnceListHasLoaded() { + doAnswer(inv -> { + openCall.set(clientThreadCalls.get()); + new Thread(() -> { + try { + Thread.sleep(200); + } catch (InterruptedException ignored) { + } + switcherLoaded.set(true); + }).start(); + return null; + }).when(client).openWorldHopper(); + + assertTrue(Microbot.hopViaWorldSwitcher(target, 3000)); + + InOrder order = inOrder(client); + order.verify(client, atLeastOnce()).openWorldHopper(); + order.verify(client).hopToWorld(target); + assertNotEquals(openCall.get(), hopCall.get()); + } + + @Test + public void hopsImmediatelyWhenSwitcherAlreadyOpen() { + switcherLoaded.set(true); + + assertTrue(Microbot.hopViaWorldSwitcher(target, 3000)); + + verify(client, never()).openWorldHopper(); + verify(client).hopToWorld(target); + } + + @Test + public void doesNotHopWhenSwitcherNeverLoads() { + assertFalse(Microbot.hopViaWorldSwitcher(target, 300)); + + verify(client, atLeastOnce()).openWorldHopper(); + verify(client, never()).hopToWorld(any()); + assertEquals(-1, hopCall.get()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java new file mode 100644 index 00000000000..056bb6ecbdd --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/ScriptStartedTest.java @@ -0,0 +1,84 @@ +package net.runelite.client.plugins.microbot; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.plugins.microbot.accountselector.AutoLoginSuppressionRequest; +import org.junit.Test; +import org.mockito.MockedStatic; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class ScriptStartedTest { + @Test public void zeroDelaySupplyFailureIsNotClearedByItsOwnStartAnnouncement() throws Exception { + EventBus bus = new EventBus(); + Block block = new Block(); + bus.register(block); + Fixture script = new Fixture(); + CountDownLatch failurePosted = new CountDownLatch(1); + try (MockedStatic microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getEventBus).thenReturn(bus); + ScheduledFuture task = script.scheduleFailure(bus, failurePosted); + assertTrue(failurePosted.await(2, TimeUnit.SECONDS)); + assertTrue(block.blocked.get()); + task.cancel(false); + } finally { + script.scheduledExecutorService.shutdownNow(); + bus.unregister(block); + } + } + + @Test public void schedulingAnnouncesStartWithoutWaitingForLoginAndOnlyOncePerRun() { + EventBus bus = new EventBus(); + Starts starts = new Starts(); + bus.register(starts); + Fixture script = new Fixture(); + try (MockedStatic microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getEventBus).thenReturn(bus); + ScheduledFuture first = script.schedule(false); + ScheduledFuture auxiliary = script.schedule(true); + assertEquals(1, starts.count); + assertSame(script, starts.last); + first.cancel(false); + auxiliary.cancel(false); + script.shutdown(); + script.schedule(false).cancel(false); + assertEquals(2, starts.count); + } finally { + script.scheduledExecutorService.shutdownNow(); + bus.unregister(starts); + } + } + + public static final class Starts { + int count; + Script last; + @Subscribe public void onScriptStarted(ScriptStarted event) { + count++; + last = event.getScript(); + } + } + + public static final class Block { + final AtomicBoolean blocked = new AtomicBoolean(); + @Subscribe public void onScriptStarted(ScriptStarted event) { blocked.set(false); } + @Subscribe public void onAutoLoginSuppressionRequest(AutoLoginSuppressionRequest event) { blocked.set(true); } + } + + private static final class Fixture extends Script { + ScheduledFuture scheduleFailure(EventBus bus, CountDownLatch posted) { + return scheduledExecutorService.scheduleWithFixedDelay(() -> { + bus.post(new AutoLoginSuppressionRequest()); + posted.countDown(); + }, 0, 1, TimeUnit.DAYS); + } + ScheduledFuture schedule(boolean fixedRate) { + return fixedRate + ? scheduledExecutorService.scheduleAtFixedRate(() -> { }, 1, 1, TimeUnit.DAYS) + : scheduledExecutorService.scheduleWithFixedDelay(() -> { }, 1, 1, TimeUnit.DAYS); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java new file mode 100644 index 00000000000..bfc4b5e0ebe --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginGateTest.java @@ -0,0 +1,37 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.breakhandler.breakhandlerv2.BreakHandlerV2State; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class AutoLoginGateTest { + @Before @After public void resetBreakHandlers() { + BreakHandlerV2State.setState(BreakHandlerV2State.WAITING_FOR_BREAK); + } + + @Test public void autoLoginRunsWhenNoBreakIsActive() { + assertFalse(AutoLoginScript.isBreakHandlerOwningLogin()); + } + + @Test public void autoLoginDefersToBreakHandlerV2DuringItsBreak() { + for (BreakHandlerV2State state : new BreakHandlerV2State[] { + BreakHandlerV2State.LOGOUT_REQUESTED, BreakHandlerV2State.LOGGED_OUT, + BreakHandlerV2State.LOGIN_REQUESTED, BreakHandlerV2State.LOGGING_IN, + BreakHandlerV2State.LOGIN_EXTENDED_SLEEP}) { + BreakHandlerV2State.setState(state); + assertTrue(state.name(), AutoLoginScript.isBreakHandlerOwningLogin()); + } + } + + @Test public void onlyTheLoginScreenStartsALogin() { + assertTrue(AutoLoginScript.canStartLogin(GameState.LOGIN_SCREEN)); + for (GameState state : new GameState[] {GameState.HOPPING, GameState.LOADING, + GameState.LOGGING_IN, GameState.CONNECTION_LOST, GameState.LOGGED_IN, + GameState.STARTING, GameState.UNKNOWN}) { + assertFalse(state.name(), AutoLoginScript.canStartLogin(state)); + } + } +} 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 new file mode 100644 index 00000000000..be62f005fa4 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginPluginSupplyStopTest.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import net.runelite.api.GameState; +import net.runelite.api.events.GameStateChanged; +import net.runelite.client.plugins.microbot.Script; +import net.runelite.client.plugins.microbot.ScriptStarted; +import net.runelite.client.plugins.microbot.breakhandler.BreakHandlerScript; +import org.junit.Test; +import static org.mockito.Mockito.*; + +public class AutoLoginPluginSupplyStopTest { + @Test public void terminalStopAndManualGameStateAreForwarded() { + AutoLoginPlugin plugin = new AutoLoginPlugin(); + plugin.accountSelectorScript = mock(AutoLoginScript.class); + plugin.onAutoLoginSuppressionRequest(new AutoLoginSuppressionRequest()); + verify(plugin.accountSelectorScript).suppressAutoLogin(); + GameStateChanged event = new GameStateChanged(); + event.setGameState(GameState.LOGIN_SCREEN); + plugin.onGameStateChanged(event); + verify(plugin.accountSelectorScript).observeSuppressionGameState(GameState.LOGIN_SCREEN); + } + + @Test public void onlyGameplayRunResumesAutoLogin() { + AutoLoginPlugin plugin = new AutoLoginPlugin(); + plugin.accountSelectorScript = mock(AutoLoginScript.class); + plugin.onScriptStarted(new ScriptStarted(mock(AutoLoginScript.class))); + plugin.onScriptStarted(new ScriptStarted(mock(BreakHandlerScript.class))); + verify(plugin.accountSelectorScript, never()).resumeAutoLogin(); + plugin.onScriptStarted(new ScriptStarted(mock(Script.class))); + verify(plugin.accountSelectorScript).resumeAutoLogin(); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java new file mode 100644 index 00000000000..b653706edac --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginRetryStateTest.java @@ -0,0 +1,190 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.security.LoginLockout; +import net.runelite.client.plugins.microbot.util.security.LoginManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +public class AutoLoginRetryStateTest { + private static final int WORLD = 383; + + private final AtomicReference gameState = new AtomicReference<>(GameState.LOGIN_SCREEN); + private final AtomicInteger loginIndex = new AtomicInteger(2); + private final AtomicInteger loginCalls = new AtomicInteger(); + private final MutableClock clock = new MutableClock(); + private Client client; + private AutoLoginConfig config; + private AutoLoginScript script; + private MockedStatic microbot; + private MockedStatic loginManager; + + @Before public void setUp() { + LoginLockout.getInstance().recordSuccessfulLogin(); + client = mock(Client.class); + when(client.getGameState()).thenAnswer(call -> gameState.get()); + when(client.getLoginIndex()).thenAnswer(call -> loginIndex.get()); + + config = mock(AutoLoginConfig.class); + when(config.usePreferredWorld()).thenReturn(true); + when(config.world()).thenReturn(WORLD); + when(config.loginRetryDelay()).thenReturn(5); + when(config.maxLoginRetries()).thenReturn(5); + when(config.enableLoginWatchdog()).thenReturn(false); + + microbot = mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::isLoggedIn).thenAnswer(call -> gameState.get() == GameState.LOGGED_IN); + + loginManager = mockStatic(LoginManager.class, CALLS_REAL_METHODS); + loginManager.when(() -> LoginManager.login(anyInt())).thenAnswer(call -> { + loginCalls.incrementAndGet(); + gameState.set(GameState.LOGGING_IN); + return true; + }); + + script = new AutoLoginScript(); + script.setClock(clock); + } + + @After public void tearDown() { + loginManager.close(); + microbot.close(); + LoginLockout.getInstance().recordSuccessfulLogin(); + } + + @Test public void failedLoginWaitsForBackoffBeforeRetrying() { + tick(); + assertEquals(1, loginCalls()); + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + + for (int i = 0; i < 5; i++) { + advance(Duration.ofMillis(600)); + tick(); + } + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + assertEquals(1, script.getRetryCount()); + + gameState.set(GameState.LOGIN_SCREEN); + tick(); + advance(Duration.ofSeconds(1)); + tick(); + assertEquals(1, loginCalls()); + assertEquals(1, script.getRetryCount()); + + advance(Duration.ofSeconds(15)); + tick(); + assertEquals(2, loginCalls()); + assertEquals(2, script.getRetryCount()); + + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofSeconds(9)); + tick(); + assertEquals(2, loginCalls()); + + advance(Duration.ofSeconds(20)); + tick(); + assertEquals(3, loginCalls()); + } + + @Test public void maxRetriesStillEntersExtendedSleepWithWatchdog() { + when(config.enableLoginWatchdog()).thenReturn(true); + when(config.loginWatchdogTimeout()).thenReturn(60); + when(config.maxLoginRetries()).thenReturn(2); + + tick(); + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofSeconds(30)); + tick(); + assertEquals(2, loginCalls()); + + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofMinutes(5)); + tick(); + assertEquals(2, loginCalls()); + assertEquals("LOGIN_EXTENDED_SLEEP", script.getLoginStateName()); + } + + @Test public void loggingInThenLoggedInResetsRetryState() { + tick(); + assertEquals(1, script.getRetryCount()); + + gameState.set(GameState.LOADING); + advance(Duration.ofMillis(600)); + tick(); + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + assertEquals(1, script.getRetryCount()); + + gameState.set(GameState.LOGGED_IN); + advance(Duration.ofMillis(600)); + tick(); + assertEquals("WAITING_FOR_LOGIN_SCREEN", script.getLoginStateName()); + assertEquals(0, script.getRetryCount()); + + gameState.set(GameState.LOGIN_SCREEN); + advance(Duration.ofMillis(600)); + tick(); + assertEquals(2, loginCalls()); + assertEquals(1, script.getRetryCount()); + } + + @Test public void lockoutScreenUsesCooldownInsteadOfBackoff() { + tick(); + gameState.set(GameState.LOGIN_SCREEN); + loginIndex.set(LoginLockout.LOGIN_INDEX_TOO_MANY_ATTEMPTS); + tick(); + assertTrue(LoginLockout.getInstance().isActive()); + + loginIndex.set(2); + for (int i = 0; i < 5; i++) { + advance(Duration.ofMinutes(1)); + tick(); + } + + assertEquals(1, loginCalls()); + assertTrue(LoginLockout.getInstance().isActive()); + assertEquals("ATTEMPTING_LOGIN", script.getLoginStateName()); + } + + private void tick() { + script.processAutoLoginStateMachine(config); + } + + private void advance(Duration duration) { + clock.now = clock.now.plus(duration); + } + + private int loginCalls() { + return loginCalls.get(); + } + + private static final class MutableClock extends Clock { + private Instant now = Instant.parse("2026-01-01T00:00:00Z"); + + @Override public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override public Clock withZone(ZoneId zone) { + return this; + } + + @Override public Instant instant() { + return now; + } + } +} 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 new file mode 100644 index 00000000000..2a92f48e678 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/AutoLoginSuppressionTest.java @@ -0,0 +1,89 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import net.runelite.api.GameState; +import org.junit.Test; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.Assert.*; + +public class AutoLoginSuppressionTest { + @Test public void ordinaryLogoutAndWorldHopDoNotSuppressAutoLogin() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + for (GameState state : new GameState[] {GameState.LOGGED_IN, GameState.LOADING, + GameState.HOPPING, GameState.LOGIN_SCREEN, GameState.LOGGING_IN, GameState.LOGGED_IN}) { + suppression.observe(state); + assertFalse(suppression.isSuppressed()); + } + } + + @Test public void restartCanClearSuppressionWithoutManualLogin() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.request(); + suppression.observe(GameState.LOGIN_SCREEN); + assertTrue(suppression.isSuppressed()); + suppression.clear(); + assertFalse(suppression.isSuppressed()); + } + @Test public void terminalLogoutRemainsSuppressedUntilManualLoginAfterLogout() { + AutoLoginSuppression suppression = new AutoLoginSuppression(); + suppression.request(); + suppression.observe(GameState.LOGGED_IN); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGIN_SCREEN); + suppression.observe(GameState.LOADING); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } + @Test public void failedLogoutExpiresWithoutAnyGameStateEvent() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + assertTrue(suppression.isSuppressed()); + clock.incrementAndGet(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void laterUnrelatedLogoutDoesNotConsumeExpiredRequest() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS); + suppression.observe(GameState.LOGIN_SCREEN); + assertFalse(suppression.isSuppressed()); + } + + @Test public void delayedLogoutWithinDeadlineRemainsSuppressedIndefinitely() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + suppression.observe(GameState.LOGIN_SCREEN); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 100); + suppression.observe(GameState.LOGIN_SCREEN); + assertTrue(suppression.isSuppressed()); + suppression.observe(GameState.LOGGED_IN); + assertFalse(suppression.isSuppressed()); + } + + @Test public void repeatedPendingRequestsDoNotExtendDeadline() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS - 1); + suppression.request(); + clock.incrementAndGet(); + assertFalse(suppression.isSuppressed()); + } + + @Test public void newRequestAfterExpiryGetsItsOwnDeadline() { + AtomicLong clock = new AtomicLong(); + AutoLoginSuppression suppression = new AutoLoginSuppression(clock::get); + suppression.request(); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS); + suppression.request(); + assertTrue(suppression.isSuppressed()); + clock.set(AutoLoginSuppression.AWAIT_LOGOUT_MILLIS * 2); + assertFalse(suppression.isSuppressed()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java new file mode 100644 index 00000000000..495c997945c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/accountselector/TerminalSupplyLogoutTest.java @@ -0,0 +1,57 @@ +package net.runelite.client.plugins.microbot.accountselector; + +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.api.Client; +import net.runelite.client.eventbus.EventBus; +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.widget.Rs2Widget; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class TerminalSupplyLogoutTest { + @Test public void newScriptDuringBankCloseStaysUnblockedAndLogoutIsObserved() { + AtomicBoolean loggedIn = new AtomicBoolean(true); + AtomicBoolean blocked = new AtomicBoolean(); + Client client = mock(Client.class); + EventBus bus = mock(EventBus.class); + doAnswer(call -> { blocked.set(true); return null; }).when(bus).post(any(AutoLoginSuppressionRequest.class)); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var widget = mockStatic(Rs2Widget.class); + var player = mockStatic(Rs2Player.class, CALLS_REAL_METHODS)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getEventBus).thenReturn(bus); + microbot.when(Microbot::isLoggedIn).thenAnswer(call -> loggedIn.get()); + bank.when(Rs2Bank::isOpen).thenReturn(true, false); + widget.when(() -> Rs2Widget.clickChildWidget(786434, 11)).thenAnswer(call -> { + blocked.set(false); // another script starts while the previous bank is closing + return true; + }); + player.when(Rs2Player::logout).thenAnswer(call -> { loggedIn.set(false); return null; }); + assertEquals(Rs2Player.TerminalLogoutState.LOGGED_OUT, Rs2Player.logoutWithoutAutoLogin()); + assertFalse(blocked.get()); + verify(bus, times(1)).post(any(AutoLoginSuppressionRequest.class)); + player.verify(Rs2Player::logout, times(1)); + } + } + + @Test public void blockedBankCloseDoesNotClaimLogoutOrDispatchLogout() { + Client client = mock(Client.class); + EventBus bus = mock(EventBus.class); + try (var microbot = mockStatic(Microbot.class); + var bank = mockStatic(Rs2Bank.class); + var widget = mockStatic(Rs2Widget.class); + var player = mockStatic(Rs2Player.class, CALLS_REAL_METHODS)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getEventBus).thenReturn(bus); + microbot.when(Microbot::isLoggedIn).thenReturn(true); + bank.when(Rs2Bank::isOpen).thenReturn(true); + assertEquals(Rs2Player.TerminalLogoutState.INPUT_BLOCKED, Rs2Player.logoutWithoutAutoLogin()); + verify(bus).post(any(AutoLoginSuppressionRequest.class)); + player.verify(Rs2Player::logout, never()); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java new file mode 100644 index 00000000000..302ba20ecea --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/AgentServerGsonTest.java @@ -0,0 +1,87 @@ +package net.runelite.client.plugins.microbot.agentserver; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class AgentServerGsonTest { + + private final Gson gson = AgentServerPlugin.createGson(); + + static class Description { + String name = "Inventory"; + List actions = Collections.emptyList(); + List children = Collections.emptyList(); + } + + @Test + public void serializesWidgetDescribeResponseWithEmptyList() { + Map response = new LinkedHashMap<>(); + response.put("groupId", 149); + response.put("childId", 0); + response.put("count", 0); + response.put("widgets", Collections.emptyList()); + + JsonObject json = gson.fromJson(gson.toJson(response), JsonObject.class); + + assertEquals(149, json.get("groupId").getAsInt()); + assertEquals(0, json.getAsJsonArray("widgets").size()); + } + + @Test + public void serializesImmutableJdkCollectionsAndMaps() { + Map response = new LinkedHashMap<>(); + response.put("singleton", Collections.singletonList("a")); + response.put("listOf", List.of(1, 2)); + response.put("unmodifiable", Collections.unmodifiableList(new ArrayList<>(Arrays.asList("x", "y")))); + response.put("setOf", Set.of("s")); + response.put("emptyMap", Collections.emptyMap()); + response.put("mapOf", Map.of("k", List.of())); + + JsonObject json = gson.fromJson(gson.toJson(response), JsonObject.class); + + assertEquals("a", json.getAsJsonArray("singleton").get(0).getAsString()); + assertEquals(2, json.getAsJsonArray("listOf").get(1).getAsInt()); + assertEquals("y", json.getAsJsonArray("unmodifiable").get(1).getAsString()); + assertEquals("s", json.getAsJsonArray("setOf").get(0).getAsString()); + assertEquals(0, json.getAsJsonObject("emptyMap").size()); + assertEquals(0, json.getAsJsonObject("mapOf").getAsJsonArray("k").size()); + } + + @Test + public void serializesNestedEmptyListFields() { + Map response = new LinkedHashMap<>(); + response.put("widgets", Collections.singletonList(new Description())); + + JsonArray widgets = gson.fromJson(gson.toJson(response), JsonObject.class).getAsJsonArray("widgets"); + + JsonObject widget = widgets.get(0).getAsJsonObject(); + assertEquals("Inventory", widget.get("name").getAsString()); + assertEquals(0, widget.getAsJsonArray("actions").size()); + assertEquals(0, widget.getAsJsonArray("children").size()); + } + + @Test + public void deserializesRequestBodiesUnchanged() { + @SuppressWarnings("unchecked") + Map parsed = gson.fromJson("{\"name\":\"x\",\"args\":[1,2],\"opts\":{\"a\":true}}", LinkedHashMap.class); + + assertEquals("x", parsed.get("name")); + assertTrue(parsed.get("args") instanceof List); + assertEquals(2, ((List) parsed.get("args")).size()); + assertTrue(parsed.get("opts") instanceof Map); + assertEquals(true, ((Map) parsed.get("opts")).get("a")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java index 74693ca5662..ad5f8c4ab30 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/AbstractEntityQueryableInterruptionTest.java @@ -88,6 +88,16 @@ public void nearestByAnchor_nullAnchorReturnsNullEvenWithInterruptibleStream() { assertNull(q.nearest(null, 10)); } + @Test + public void withinAnchor_skipsEntitiesWithoutLocation() { + TestEntity detached = new TestEntity(null); + TestEntity near = new TestEntity(new WorldPoint(3201, 3200, 0)); + + TestQueryable q = new TestQueryable(Stream.of(detached, near)); + java.util.List result = q.within(new WorldPoint(3200, 3200, 0), 5).toList(); + assertTrue(result.size() == 1 && result.get(0) == near); + } + // ---- Test fixtures ------------------------------------------------------- private static class TestQueryable extends AbstractEntityQueryable { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java new file mode 100644 index 00000000000..c8e58394d23 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/player/Rs2PlayerModelNullLocalPlayerTest.java @@ -0,0 +1,62 @@ +package net.runelite.client.plugins.microbot.api.player; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class Rs2PlayerModelNullLocalPlayerTest { + private final Map original = new HashMap<>(); + private ClientThread clientThread; + + @Before + public void setUp() throws Exception { + Client client = mock(Client.class); + clientThread = mock(ClientThread.class); + replace("client", client); + replace("clientThread", clientThread); + when(client.getLocalPlayer()).thenReturn(null); + } + + private void replace(String name, Object value) throws Exception { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + original.put(name, field.get(null)); + field.set(null, value); + } + + @After + public void tearDown() throws Exception { + for (Map.Entry entry : original.entrySet()) { + Field field = Microbot.class.getDeclaredField(entry.getKey()); + field.setAccessible(true); + field.set(null, entry.getValue()); + } + } + + @Test + public void positionAccessorsReturnNullWithoutLocalPlayer() { + Rs2PlayerModel player = new Rs2PlayerModel(); + assertNull(player.getWorldLocation()); + assertNull(player.getWorldView()); + assertNull(player.getLocalLocation()); + assertNull(player.projectActorLocationToMainWorld()); + verifyNoInteractions(clientThread); + } + + @Test + public void animationIsIdleWithoutLocalPlayer() { + assertEquals(-1, new Rs2PlayerModel().getAnimation()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java new file mode 100644 index 00000000000..e56d9d23469 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/api/tileitem/Rs2TileItemModelDetachedTileTest.java @@ -0,0 +1,48 @@ +package net.runelite.client.plugins.microbot.api.tileitem; + +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.api.tileitem.models.Rs2TileItemModel; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2TileItemModelDetachedTileTest { + + @Test + public void locationAccessorsReturnNullWhenTileWorldViewIsGone() { + Tile tile = mock(Tile.class); + when(tile.getWorldLocation()).thenThrow(new NullPointerException("Cannot invoke \"net.runelite.api.WorldView.getBaseX()\" because \"wv\" is null")); + when(tile.getLocalLocation()).thenThrow(new NullPointerException()); + Rs2TileItemModel model = new Rs2TileItemModel(tile, mock(TileItem.class)); + + assertNull(model.getWorldLocation()); + assertNull(model.getLocalLocation()); + } + + @Test + public void locationAccessorsReturnNullWithoutTile() { + Rs2TileItemModel model = new Rs2TileItemModel(null, mock(TileItem.class)); + + assertNull(model.getWorldLocation()); + assertNull(model.getLocalLocation()); + } + + @Test + public void locationAccessorsDelegateToTile() { + Tile tile = mock(Tile.class); + WorldPoint worldPoint = new WorldPoint(3200, 3200, 0); + LocalPoint localPoint = new LocalPoint(6400, 6400, -1); + when(tile.getWorldLocation()).thenReturn(worldPoint); + when(tile.getLocalLocation()).thenReturn(localPoint); + Rs2TileItemModel model = new Rs2TileItemModel(tile, mock(TileItem.class)); + + assertEquals(worldPoint, model.getWorldLocation()); + assertEquals(localPoint, model.getLocalLocation()); + } +} 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 4e2b75375a7..4b3cf86854e 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 @@ -193,6 +193,23 @@ public void requeuesRejectedBatch() assertEquals(2, captor.getValue().getAsJsonArray("errors").get(0).getAsJsonObject().get("count").getAsInt()); } + @Test + public void ignoresInterruptedWaits() + { + log(Level.ERROR, "AttackNpcScript", new RuntimeException("Interrupted waiting for client thread", new InterruptedException())); + log(Level.ERROR, "Error displaying message {}:", new InterruptedException()); + assertEquals(0, reporter.pendingCount()); + } + + @Test + public void requeuesBatchWhenSubmitThrows() + { + org.mockito.Mockito.doThrow(new IllegalArgumentException("bad url")).when(api).submitErrors(any(), any()); + log(Level.ERROR, "loop failed", boom()); + reporter.flush(); + assertEquals(1, reporter.pendingCount()); + } + @Test public void groupsMessagesThatOnlyDifferInNumbers() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java index 1c632fcd9a4..b137917e0c5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/externalplugins/MicrobotPluginManagerHealthTest.java @@ -1,6 +1,10 @@ package net.runelite.client.plugins.microbot.externalplugins; import com.google.gson.Gson; +import com.google.inject.Binder; +import com.google.inject.Guice; +import com.google.inject.Injector; +import net.runelite.client.RuneLite; import net.runelite.client.config.ConfigManager; import net.runelite.client.eventbus.EventBus; import net.runelite.client.events.ExternalPluginsChanged; @@ -46,6 +50,22 @@ public static class JarDisabledFixturePlugin extends Plugin { public static class FutureClientFixturePlugin extends Plugin { } + @PluginDescriptor(name = "Injector Failure Fixture", isExternal = true, version = "1.0.0", minClientVersion = "0.0.1") + public static class InjectorFailureFixturePlugin extends Plugin { + @Override + public void configure(Binder binder) { + throw new IllegalStateException("injector failure"); + } + } + + @PluginDescriptor(name = "Linkage Failure Fixture", isExternal = true, version = "1.0.0", minClientVersion = "0.0.1") + public static class LinkageFailureFixturePlugin extends Plugin { + @Override + public void configure(Binder binder) { + throw new NoClassDefFoundError("missing/ApiClass"); + } + } + private MicrobotPluginManager manager; private PluginManager pluginManager; private ConfigManager configManager; @@ -188,4 +208,28 @@ public void removeLeavesSameNamedPluginFromAnotherClassLoaderRunning() throws Ex verify(pluginManager, never()).remove(corePlugin); verify(configManager).unsetConfiguration(eq("microbotPluginVersions"), eq("plugin.HealthFixturePlugin")); } + + @Test + public void pluginWithoutInjectorIsNotRegistered() throws Exception { + assertTrue(loadPlugins(InjectorFailureFixturePlugin.class).isEmpty()); + + verify(pluginManager, never()).addPlugin(any(Plugin.class)); + } + + @Test + public void failingPluginDoesNotStopOthersFromLoading() throws Exception { + Injector previous = RuneLite.getInjector(); + RuneLite.setInjector(Guice.createInjector()); + try { + List loaded = loadPlugins(InjectorFailureFixturePlugin.class, LinkageFailureFixturePlugin.class, HealthFixturePlugin.class); + + assertEquals(1, loaded.size()); + assertTrue(loaded.get(0) instanceof HealthFixturePlugin); + verify(pluginManager).addPlugin(loaded.get(0)); + verify(pluginManager, never()).addPlugin(any(InjectorFailureFixturePlugin.class)); + verify(pluginManager, never()).addPlugin(any(LinkageFailureFixturePlugin.class)); + } finally { + RuneLite.setInjector(previous); + } + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java new file mode 100644 index 00000000000..f695620e898 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/config/SkillFilteringTest.java @@ -0,0 +1,45 @@ +package net.runelite.client.plugins.microbot.questhelper.config; + +import java.lang.reflect.Field; +import net.runelite.api.Skill; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperConfig; +import net.runelite.client.plugins.microbot.questhelper.helpers.quests.currentaffairs.CurrentAffairs; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SkillFilteringTest +{ + private ConfigManager configManager; + private CurrentAffairs helper; + + @Before + public void setUp() throws Exception + { + configManager = mock(ConfigManager.class); + helper = new CurrentAffairs(); + Field field = QuestHelper.class.getDeclaredField("configManager"); + field.setAccessible(true); + field.set(helper, configManager); + } + + @Test + public void initializesRequirementsBeforeReadingThem() + { + assertTrue(SkillFiltering.questPassesSkillFilter(helper)); + } + + @Test + public void filtersOutQuestRequiringFilteredSkill() + { + when(configManager.getConfiguration(QuestHelperConfig.QUEST_BACKGROUND_GROUP, "skillfilter" + Skill.SAILING.getName())) + .thenReturn("true"); + assertFalse(SkillFiltering.questPassesSkillFilter(helper)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java new file mode 100644 index 00000000000..b460396f3db --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/PathMinimapOverlayTest.java @@ -0,0 +1,65 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.lang.reflect.Constructor; +import java.util.Collections; +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PathMinimapOverlayTest +{ + private Pathfinder previousPathfinder; + + @Before + public void saveStaticPathfinder() + { + previousPathfinder = ShortestPathPlugin.pathfinder; + } + + @After + public void restoreStaticPathfinder() + { + ShortestPathPlugin.pathfinder = previousPathfinder; + } + + @Test + public void hiddenMinimapKeepsGraphicsClip() throws Exception + { + ShortestPathPlugin plugin = mock(ShortestPathPlugin.class); + plugin.drawMinimap = true; + when(plugin.getMinimapClipArea()).thenReturn(null); + + Pathfinder pathfinder = mock(Pathfinder.class); + when(pathfinder.getPath()).thenReturn(Collections.emptyList()); + ShortestPathPlugin.pathfinder = pathfinder; + + Constructor constructor = PathMinimapOverlay.class + .getDeclaredConstructor(Client.class, ShortestPathPlugin.class, ShortestPathConfig.class); + constructor.setAccessible(true); + PathMinimapOverlay overlay = constructor.newInstance(mock(Client.class), plugin, mock(ShortestPathConfig.class)); + + Rectangle clip = new Rectangle(0, 0, 50, 50); + Graphics2D graphics = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB).createGraphics(); + graphics.setClip(clip); + try + { + overlay.render(graphics); + assertNotNull(graphics.getClip()); + assertEquals(clip, graphics.getClip()); + } + finally + { + graphics.dispose(); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index 959e6d95d0d..fa3606b772f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -1,6 +1,8 @@ package net.runelite.client.plugins.microbot.shortestpath; +import net.runelite.api.Client; import net.runelite.api.Quest; +import net.runelite.api.WorldType; import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; @@ -11,8 +13,10 @@ import org.junit.Test; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -24,6 +28,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * Route-regression corpus: real end-to-end routes computed with the real pathfinder, collision map and @@ -821,6 +827,56 @@ public void draynorRoadPassesTheClosedSewerTrapdoorWithoutUsingIt() { assertFalse(path.stream().anyMatch(point -> point.getY() > 9000)); } + private static final WorldPoint PORT_SARIM_MANHOLE = new WorldPoint(3018, 3231, 0); + private static final WorldPoint DRAYNOR_BANK = new WorldPoint(3092, 3245, 0); + + private static List portSarimRatPitTransports() { + return allTransports.values().stream() + .flatMap(Set::stream) + .filter(t -> t.getObjectId() == 10321 || t.getObjectId() == 10309) + .filter(t -> t.getOrigin() != null && t.getDestination() != null) + .filter(t -> t.getOrigin().equals(PORT_SARIM_MANHOLE) + || t.getOrigin().equals(new WorldPoint(2962, 9650, 0))) + .collect(Collectors.toList()); + } + + @Test + public void portSarimRatPitManholeAndLadderAreMembersOnly() { + List rows = portSarimRatPitTransports(); + assertEquals("both Port Sarim rat-pit rows must stay in the catalog", 2, rows.size()); + for (Transport row : rows) { + assertTrue("rat-pit transport must be members-only: " + row, row.isMembers()); + } + } + + @Test + public void portSarimRatPitManholeIsNotAdmittedOnFreeWorld() throws Exception { + Client client = mock(Client.class); + when(client.getWorldType()).thenReturn(EnumSet.noneOf(WorldType.class)); + PathfinderConfig config = new PathfinderConfig( + collisionMap, new HashMap<>(), Collections.emptyList(), client, null); + Method useTransport = PathfinderConfig.class.getDeclaredMethod( + "useTransport", Transport.class, boolean.class); + useTransport.setAccessible(true); + + List rows = portSarimRatPitTransports(); + assertFalse(rows.isEmpty()); + for (Transport row : rows) { + assertFalse("free world must not admit " + row, (Boolean) useTransport.invoke(config, row, false)); + } + } + + @Test + public void portSarimToDraynorBankOnFreeWorldStaysOnTheSurface() { + Pathfinder pathfinder = runPathfinder(configWith(t -> unrestricted(t) && !t.isMembers()), + new WorldPoint(3018, 3229, 0), DRAYNOR_BANK); + List path = pathfinder.getPath(); + + assertTrue(arrives(path, DRAYNOR_BANK, 2)); + assertFalse(selectsTransportObject(pathfinder, 10321)); + assertFalse(path.stream().anyMatch(point -> point.getY() > 9000)); + } + @Test public void zanarisShedDoorRequiresLostCity() { Transport shed = allTransports.getOrDefault(new WorldPoint(3202, 3169, 0), Collections.emptySet()).stream() diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java new file mode 100644 index 00000000000..46761f63350 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/FarmedSpiritTreeDefaultsTest.java @@ -0,0 +1,118 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathConfig; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class FarmedSpiritTreeDefaultsTest +{ + private static final List QUEST_TREES = Arrays.asList( + new WorldPoint(2542, 3170, 0), + new WorldPoint(2461, 3444, 0), + new WorldPoint(2555, 3259, 0), + new WorldPoint(3185, 3508, 0)); + + @Test + public void farmedTreeTogglesDefaultOff() + { + ShortestPathConfig defaults = new ShortestPathConfig() { }; + assertFalse(defaults.spiritTreeEtceteria()); + assertFalse(defaults.spiritTreeBrimhaven()); + assertFalse(defaults.spiritTreePortSarim()); + assertFalse(defaults.spiritTreeHosidius()); + assertFalse(defaults.spiritTreeFarmingGuild()); + assertTrue(defaults.useSpiritTrees()); + } + + @Test + public void defaultConfigAdmitsOnlyQuestSpiritTrees() throws Exception + { + PathfinderConfig config = new PathfinderConfig( + null, Collections.emptyMap(), Collections.emptyList(), null, null, + Rs2TransportPlanningPolicy.INSTANCE); + ShortestPathConfig defaults = new ShortestPathConfig() { }; + set(config, "useSpiritTreeEtceteria", defaults.spiritTreeEtceteria()); + set(config, "useSpiritTreeBrimhaven", defaults.spiritTreeBrimhaven()); + set(config, "useSpiritTreePortSarim", defaults.spiritTreePortSarim()); + set(config, "useSpiritTreeHosidius", defaults.spiritTreeHosidius()); + set(config, "useSpiritTreeFarmingGuild", defaults.spiritTreeFarmingGuild()); + + Field farmedField = PathfinderConfig.class.getDeclaredField("SPIRIT_TREE_DESTINATIONS_ORDERED"); + farmedField.setAccessible(true); + List farmed = Arrays.asList((WorldPoint[]) farmedField.get(null)); + Method enabled = PathfinderConfig.class.getDeclaredMethod("isSpiritTreeRouteEnabled", Transport.class); + enabled.setAccessible(true); + + List spiritTrees = Transport.loadAllFromResources().values().stream() + .flatMap(Set::stream) + .filter(t -> t.getType() == TransportType.SPIRIT_TREE) + .collect(Collectors.toList()); + assertFalse(spiritTrees.isEmpty()); + + int farmedRoutes = 0; + for (Transport t : spiritTrees) + { + if (near(t.getOrigin(), farmed) || near(t.getDestination(), farmed)) + { + farmedRoutes++; + assertFalse("farmed spirit tree admitted by default: " + t.getOrigin() + " -> " + t.getDestination(), + (boolean) enabled.invoke(config, t)); + } + } + assertTrue(farmedRoutes > 0); + + for (WorldPoint from : QUEST_TREES) + { + for (WorldPoint to : QUEST_TREES) + { + if (from.equals(to)) + { + continue; + } + boolean admitted = spiritTrees.stream() + .filter(t -> t.getOrigin() != null && t.getOrigin().distanceTo2D(from) <= 5) + .filter(t -> to.equals(t.getDestination())) + .anyMatch(t -> invoke(enabled, config, t)); + assertTrue("quest spirit tree route missing: " + from + " -> " + to, admitted); + } + } + } + + private static boolean near(WorldPoint point, List targets) + { + return point != null && targets.stream().anyMatch(target -> point.distanceTo2D(target) <= 5); + } + + private static boolean invoke(Method method, PathfinderConfig config, Transport transport) + { + try + { + return (boolean) method.invoke(config, transport); + } + catch (ReflectiveOperationException e) + { + throw new AssertionError(e); + } + } + + private static void set(PathfinderConfig config, String name, boolean value) throws Exception + { + Field field = PathfinderConfig.class.getDeclaredField(name); + field.setAccessible(true); + field.setBoolean(config, value); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java new file mode 100644 index 00000000000..6560a0077ae --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigRequirementLevelsTest.java @@ -0,0 +1,49 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.Skill; +import net.runelite.api.VarPlayer; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PathfinderConfigRequirementLevelsTest { + + @Test + public void readsRequirementLevelsIntoCallerOwnedArray() { + Client client = mock(Client.class); + Player player = mock(Player.class); + when(client.getBoostedSkillLevel(any(Skill.class))).thenReturn(50); + when(client.getBoostedSkillLevel(Skill.AGILITY)).thenReturn(73); + when(client.getTotalLevel()).thenReturn(1500); + when(client.getLocalPlayer()).thenReturn(player); + when(player.getCombatLevel()).thenReturn(96); + when(client.getVarpValue(VarPlayer.QUEST_POINTS)).thenReturn(120); + + int[] levels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + PathfinderConfig.readRequirementLevels(client, levels); + + assertEquals(73, levels[Skill.AGILITY.ordinal()]); + assertEquals(50, levels[Skill.ATTACK.ordinal()]); + assertEquals(1500, levels[Transport.TOTAL_LEVEL_INDEX]); + assertEquals(96, levels[Transport.COMBAT_LEVEL_INDEX]); + assertEquals(120, levels[Transport.QUEST_POINTS_INDEX]); + } + + @Test + public void missingLocalPlayerReportsZeroCombatLevel() { + Client client = mock(Client.class); + when(client.getLocalPlayer()).thenReturn(null); + + int[] levels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + levels[Transport.COMBAT_LEVEL_INDEX] = 99; + PathfinderConfig.readRequirementLevels(client, levels); + + assertEquals(0, levels[Transport.COMBAT_LEVEL_INDEX]); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java new file mode 100644 index 00000000000..47c7bf506b7 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/statemachine/StateMachineScriptRestartTest.java @@ -0,0 +1,128 @@ +package net.runelite.client.plugins.microbot.statemachine; + +import java.util.ArrayList; +import java.util.List; +import net.runelite.client.plugins.microbot.Microbot; +import org.junit.Test; +import org.mockito.MockedStatic; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +public class StateMachineScriptRestartTest { + private static final String NAME = Machine.class.getSimpleName(); + + @Test public void restartReRegistersAndBeginsFromInitialState() { + Machine machine = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + machine.advance = true; + assertTrue(machine.step()); + assertEquals(Machine.State.WORKING, machine.getCurrentState()); + assertSame(machine, StateMachineScript.getRegistry().get(NAME)); + + machine.shutdown(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + assertNull(machine.getSnapshot()); + + machine.advance = false; + assertTrue(machine.step()); + assertEquals(Machine.State.IDLE, machine.getCurrentState()); + assertSame(machine, StateMachineScript.getRegistry().get(NAME)); + StateSnapshot snapshot = machine.getSnapshot(); + assertEquals(Machine.State.IDLE, snapshot.currentState()); + assertNull(snapshot.previousState()); + assertEquals(1, snapshot.loopCount()); + assertEquals(0, snapshot.transitionCount()); + assertTrue(snapshot.recentTrace().isEmpty()); + assertEquals(List.of(Machine.State.WORKING, Machine.State.IDLE), machine.visited); + + machine.shutdown(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + } finally { + machine.shutdown(); + machine.dispose(); + } + } + + @Test public void staleInstanceStopDoesNotUnregisterItsReplacement() { + Machine first = new Machine(); + Machine second = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + first.step(); + second.step(); + first.shutdown(); + assertSame(second, StateMachineScript.getRegistry().get(NAME)); + second.shutdown(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + } finally { + first.shutdown(); + second.shutdown(); + first.dispose(); + second.dispose(); + } + } + + @Test public void cancelledStepAfterStopDoesNotReRegister() { + Machine machine = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + machine.step(); + machine.shutdown(); + Thread.currentThread().interrupt(); + try { + assertFalse(machine.step()); + } finally { + Thread.interrupted(); + } + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + assertNull(machine.getSnapshot()); + } finally { + machine.shutdown(); + machine.dispose(); + } + } + + @Test public void stopDuringStateActionDoesNotRepublishSnapshot() { + Machine machine = new Machine(); + try (MockedStatic ignored = mockStatic(Microbot.class)) { + machine.stopInAction = true; + machine.step(); + assertFalse(StateMachineScript.getRegistry().containsKey(NAME)); + assertNull(machine.getSnapshot()); + } finally { + machine.shutdown(); + machine.dispose(); + } + } + + private static final class Machine extends StateMachineScript { + enum State { IDLE, WORKING } + + boolean advance; + boolean stopInAction; + final List visited = new ArrayList<>(); + + @Override protected State onError(State state, Exception e) { + return State.WORKING; + } + + @Override protected State initialState() { + return State.IDLE; + } + + @Override protected List> defineTransitions() { + return List.of(Transition.from(State.IDLE).when(() -> advance, "advance") + .because("advance").goTo(State.WORKING)); + } + + @Override protected void onState(State state) { + visited.add(state); + if (stopInAction) { + shutdown(); + throw new IllegalStateException("stopped"); + } + } + + void dispose() { + scheduledExecutorService.shutdownNow(); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java index e60abe0d501..5d820c1111a 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java @@ -4,6 +4,7 @@ import net.runelite.client.plugins.PluginDescriptor; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -37,4 +38,12 @@ public void localPlayerDoesNotMakeOtherGameStatesPlayable() { assertFalse(TestRunnerPlugin.isClientReady(GameState.LOADING, true, false)); assertFalse(TestRunnerPlugin.isClientReady(GameState.HOPPING, true, false)); } + + @Test + public void testWorldPropertyParsesWorldNumbers() { + assertEquals(383, TestRunnerPlugin.parseWorld(" 383 ")); + assertEquals(-1, TestRunnerPlugin.parseWorld(null)); + assertEquals(-1, TestRunnerPlugin.parseWorld("")); + assertEquals(-1, TestRunnerPlugin.parseWorld("f2p")); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java new file mode 100644 index 00000000000..176a9f19d9f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ClientLogScannerTest.java @@ -0,0 +1,62 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ClientLogScannerTest { + + private static final String START_OLD = "2026-10-10 18:00:00 CEST [main] INFO net.runelite.client.RuneLite - RuneLite 1.13.1 (launcher version 2.7.6) starting up, args: none"; + private static final String START_NEW = "2026-10-10 19:00:00 CEST [main] INFO net.runelite.client.RuneLite - RuneLite 1.13.1 (launcher version 2.7.6) starting up, args: --disable-telemetry"; + private static final String OLD_ERROR = "2026-10-10 18:10:00 CEST [Client] ERROR n.r.c.p.m.Microbot - previous run"; + private static final String INFO = "2026-10-10 19:00:01 CEST [Client] INFO n.r.c.p.m.Microbot - fine"; + private static final String ERROR = "2026-10-10 19:00:02 CEST [Client] ERROR n.r.c.p.m.Microbot - Failed to hop to world 552"; + private static final String STACK = "java.lang.IllegalStateException: boom"; + private static final String EXAMPLE_INTERRUPT = "2026-10-10 19:00:03 CEST [StateMachineExampleScript-5] ERROR n.r.c.p.m.s.StateMachineExampleScript - [StateMachineExample] Error in state SCAN_OBJECTS: Interrupted waiting for client thread"; + private static final String EXAMPLE_OTHER = "2026-10-10 19:00:04 CEST [StateMachineExampleScript-5] ERROR n.r.c.p.m.s.StateMachineExampleScript - [StateMachineExample] Error in state SCAN_OBJECTS: NullPointerException"; + + @Test + public void currentRunStartsAtTheLastStartupBanner() { + List run = ClientLogScanner.currentRun(List.of(START_OLD, OLD_ERROR, START_NEW, INFO)); + assertEquals(List.of(START_NEW, INFO), run); + } + + @Test + public void currentRunKeepsTheWholeFileWhenTheBannerRolledAway() { + assertEquals(List.of(INFO, ERROR), ClientLogScanner.currentRun(List.of(INFO, ERROR))); + } + + @Test + public void onlyErrorLevelLinesCount() { + assertTrue(ClientLogScanner.isErrorLine(ERROR)); + assertFalse(ClientLogScanner.isErrorLine(INFO)); + assertFalse(ClientLogScanner.isErrorLine("2026-10-10 19:00:02 CEST [Client] WARN n.r.c.Foo - ERROR in message text")); + assertFalse(ClientLogScanner.isErrorLine(STACK)); + } + + @Test + public void errorsCarryTheirFirstContinuationLine() { + List errors = ClientLogScanner.findErrors(List.of(START_NEW, ERROR, STACK, INFO), List.of()); + assertEquals(List.of(ERROR + " | " + STACK), errors); + } + + @Test + public void allowlistedLinesAreIgnored() { + List allow = List.of(new ClientLogScanner.AllowRule("Failed to hop", "test")); + assertTrue(ClientLogScanner.findErrors(List.of(ERROR, INFO), allow).isEmpty()); + } + + @Test + public void defaultAllowlistIsNarrowAndDocumented() { + List errors = ClientLogScanner.findErrors(List.of(EXAMPLE_INTERRUPT, EXAMPLE_OTHER, ERROR), ClientLogScanner.DEFAULT_ALLOWLIST); + assertEquals(List.of(EXAMPLE_OTHER, ERROR), errors); + for (ClientLogScanner.AllowRule rule : ClientLogScanner.DEFAULT_ALLOWLIST) { + assertFalse(rule.getReason().isBlank()); + } + assertTrue(ClientLogScanner.DEFAULT_ALLOWLIST.size() <= 5); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java new file mode 100644 index 00000000000..5fd3afff5ef --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseCheckRunnerTest.java @@ -0,0 +1,223 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ReleaseCheckRunnerTest { + + private static final ReleaseCheck.Body PASS = context -> { + }; + + private static List catalog() { + return List.of( + ReleaseCheck.quick("boot", "boot", 1000, PASS), + ReleaseCheck.full("world-hop", "hop", 1000, PASS), + ReleaseCheck.quick("bank", "bank", 1000, PASS), + ReleaseCheck.quick("logout", "logout", 1000, PASS).alwaysRun()); + } + + private static List ids(List checks) { + return checks.stream().map(ReleaseCheck::getId).collect(Collectors.toList()); + } + + private static ReleaseCheckRunner runner(boolean failFast, long totalMs) { + return new ReleaseCheckRunner(new ReleaseCheckRunner.Options() + .failFast(failFast) + .totalTimeoutMs(totalMs) + .cleanupTimeoutMs(2000) + .terminationWaitMs(2000)); + } + + @Test + public void quickTierSkipsFullChecksAndKeepsOrder() { + assertEquals(List.of("boot", "bank", "logout"), ids(ReleaseCheckRunner.select(catalog(), ReleaseTier.QUICK, List.of()))); + } + + @Test + public void fullTierIncludesEveryCheckInDeclaredOrder() { + assertEquals(List.of("boot", "world-hop", "bank", "logout"), ids(ReleaseCheckRunner.select(catalog(), ReleaseTier.FULL, null))); + } + + @Test + public void explicitIdsIgnoreTierAndKeepDeclaredOrder() { + assertEquals(List.of("world-hop", "bank"), ids(ReleaseCheckRunner.select(catalog(), ReleaseTier.QUICK, List.of("BANK", " world-hop ")))); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownIdIsRejected() { + ReleaseCheckRunner.select(catalog(), ReleaseTier.QUICK, List.of("bank", "nope")); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicateIdsAreRejected() { + ReleaseCheckRunner.select(List.of(ReleaseCheck.quick("a", "a", 10, PASS), ReleaseCheck.quick("A", "b", 10, PASS)), + ReleaseTier.QUICK, List.of()); + } + + @Test + public void tierParsingDefaultsToQuick() { + assertEquals(ReleaseTier.QUICK, ReleaseTier.parse(null)); + assertEquals(ReleaseTier.FULL, ReleaseTier.parse(" Full ")); + try { + ReleaseTier.parse("medium"); + fail("expected rejection"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("medium")); + } + } + + @Test + public void classifiesPassFailSkipAndUnexpectedExceptions() { + List checks = List.of( + ReleaseCheck.quick("pass", "p", 1000, context -> context.detail("seen", 3)), + ReleaseCheck.quick("fail", "f", 1000, context -> context.require(false, "expected failure")), + ReleaseCheck.quick("skip", "s", 1000, context -> context.skip("no coins")), + ReleaseCheck.quick("boom", "b", 1000, context -> { + throw new IllegalStateException("kaput"); + })); + + List outcomes = runner(false, 10_000).run(checks, null); + + assertEquals(CheckStatus.PASSED, outcomes.get(0).status); + assertEquals("3", outcomes.get(0).details.get("seen")); + assertNull(outcomes.get(0).detail); + assertEquals(CheckStatus.FAILED, outcomes.get(1).status); + assertEquals("expected failure", outcomes.get(1).detail); + assertEquals(CheckStatus.SKIPPED, outcomes.get(2).status); + assertEquals("no coins", outcomes.get(2).detail); + assertEquals(CheckStatus.FAILED, outcomes.get(3).status); + assertEquals("IllegalStateException: kaput", outcomes.get(3).detail); + assertEquals(List.of(1, 2, 3, 4), outcomes.stream().map(o -> o.order).collect(Collectors.toList())); + } + + @Test + public void continuesAfterFailureByDefault() { + List checks = List.of( + ReleaseCheck.quick("first", "f", 1000, context -> context.require(false, "nope")), + ReleaseCheck.quick("second", "s", 1000, PASS)); + + List outcomes = runner(false, 10_000).run(checks, null); + + assertEquals(CheckStatus.FAILED, outcomes.get(0).status); + assertEquals(CheckStatus.PASSED, outcomes.get(1).status); + } + + @Test + public void failFastMarksRemainingChecksNotRunButStillRunsAlwaysRunChecks() { + AtomicBoolean secondRan = new AtomicBoolean(); + AtomicBoolean logoutRan = new AtomicBoolean(); + List checks = List.of( + ReleaseCheck.quick("first", "f", 1000, context -> context.require(false, "nope")), + ReleaseCheck.quick("second", "s", 1000, context -> secondRan.set(true)), + ReleaseCheck.quick("logout", "l", 1000, context -> logoutRan.set(true)).alwaysRun()); + + List outcomes = runner(true, 10_000).run(checks, null); + + assertEquals(CheckStatus.NOT_RUN, outcomes.get(1).status); + assertTrue(outcomes.get(1).detail.contains("fail-fast after first")); + assertFalse(secondRan.get()); + assertEquals(CheckStatus.PASSED, outcomes.get(2).status); + assertTrue(logoutRan.get()); + } + + @Test + public void perCheckTimeoutInterruptsTheBodyAndRunsCleanup() throws Exception { + CountDownLatch interrupted = new CountDownLatch(1); + AtomicBoolean cleaned = new AtomicBoolean(); + List checks = List.of( + ReleaseCheck.quick("slow", "s", 200, context -> { + context.onCleanup("restore", () -> cleaned.set(true)); + try { + Thread.sleep(10_000); + } catch (InterruptedException e) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + }), + ReleaseCheck.quick("next", "n", 1000, PASS)); + + List outcomes = runner(false, 10_000).run(checks, null); + + assertEquals(CheckStatus.FAILED, outcomes.get(0).status); + assertEquals("Timed out after 200ms", outcomes.get(0).detail); + assertTrue(interrupted.await(2, TimeUnit.SECONDS)); + assertTrue(cleaned.get()); + assertEquals(CheckStatus.PASSED, outcomes.get(1).status); + } + + @Test + public void totalTimeoutCapsTheCheckBudgetAndSkipsTheRest() { + AtomicBoolean logoutRan = new AtomicBoolean(); + List checks = List.of( + ReleaseCheck.quick("hog", "h", 5_000, context -> { + try { + Thread.sleep(10_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }), + ReleaseCheck.quick("later", "l", 1000, PASS), + ReleaseCheck.quick("logout", "o", 1000, context -> logoutRan.set(true)).alwaysRun()); + + List outcomes = runner(false, 300).run(checks, null); + + assertEquals(CheckStatus.FAILED, outcomes.get(0).status); + assertTrue(outcomes.get(0).detail, outcomes.get(0).detail.contains("remaining total budget")); + assertEquals(CheckStatus.NOT_RUN, outcomes.get(1).status); + assertTrue(outcomes.get(1).detail.contains("total timeout of 300ms exceeded")); + assertEquals(CheckStatus.PASSED, outcomes.get(2).status); + assertTrue(logoutRan.get()); + } + + @Test + public void cleanupsRunInReverseRegistrationOrder() { + List order = Collections.synchronizedList(new ArrayList<>()); + List checks = List.of(ReleaseCheck.quick("c", "c", 1000, context -> { + context.onCleanup("first", () -> order.add("first")); + context.onCleanup("second", () -> order.add("second")); + context.skip("account state"); + })); + + CheckOutcome outcome = runner(false, 10_000).run(checks, null).get(0); + + assertEquals(CheckStatus.SKIPPED, outcome.status); + assertEquals(List.of("second", "first"), order); + } + + @Test + public void failingCleanupTurnsAPassIntoAFailure() { + List checks = List.of(ReleaseCheck.quick("c", "c", 1000, context -> { + context.onCleanup("close bank", () -> { + throw new IllegalStateException("bank still open"); + }); + context.onCleanup("other", () -> { + }); + })); + + CheckOutcome outcome = runner(false, 10_000).run(checks, null).get(0); + + assertEquals(CheckStatus.FAILED, outcome.status); + assertEquals(List.of("close bank: IllegalStateException: bank still open"), outcome.cleanupErrors); + assertTrue(outcome.detail.startsWith("Cleanup failed: close bank")); + } + + @Test + public void listenerSeesEveryOutcomeInOrder() { + List seen = new ArrayList<>(); + runner(true, 10_000).run(ReleaseCheckRunner.select(catalog(), ReleaseTier.FULL, List.of()), outcome -> seen.add(outcome.id + ":" + outcome.status)); + assertEquals(List.of("boot:PASSED", "world-hop:PASSED", "bank:PASSED", "logout:PASSED"), seen); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java new file mode 100644 index 00000000000..7c8c888b643 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/release/ReleaseSmokeResultTest.java @@ -0,0 +1,155 @@ +package net.runelite.client.plugins.microbot.testing.release; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.runelite.client.plugins.microbot.testing.TestResultWriter; +import net.runelite.http.api.worlds.World; +import net.runelite.http.api.worlds.WorldType; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ReleaseSmokeResultTest { + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private static CheckOutcome outcome(int order, String id, CheckStatus status, String detail) { + CheckOutcome outcome = new CheckOutcome(); + outcome.order = order; + outcome.id = id; + outcome.description = id + " description"; + outcome.tier = ReleaseTier.QUICK; + outcome.status = status; + outcome.durationMs = 1500; + outcome.detail = detail; + return outcome; + } + + @Test + public void passedAndSkippedChecksExitZero() { + ReleaseSmokeResult result = new ReleaseSmokeResult("Release Smoke Harness"); + result.record(outcome(1, "boot", CheckStatus.PASSED, null)); + result.record(outcome(2, "bank", CheckStatus.SKIPPED, "no junk item")); + result.finish("completed"); + + assertEquals(0, result.exitCode); + assertEquals("PASS", result.verdict); + assertEquals(1, result.passed); + assertEquals(1, result.skipped); + } + + @Test + public void failedOrNotRunChecksExitNonZero() { + ReleaseSmokeResult failed = new ReleaseSmokeResult("h"); + failed.record(outcome(1, "boot", CheckStatus.FAILED, "bad")); + failed.finish("completed"); + assertEquals(1, failed.exitCode); + assertEquals("FAIL", failed.verdict); + + ReleaseSmokeResult notRun = new ReleaseSmokeResult("h"); + notRun.record(outcome(1, "boot", CheckStatus.PASSED, null)); + notRun.record(outcome(2, "bank", CheckStatus.NOT_RUN, "Not run: total timeout")); + notRun.finish("completed"); + assertEquals(1, notRun.exitCode); + assertEquals(1, notRun.notRun); + } + + @Test + public void emptyRunsAndCrashesAreFailures() { + ReleaseSmokeResult empty = new ReleaseSmokeResult("h"); + empty.finish("completed"); + assertEquals(1, empty.exitCode); + + ReleaseSmokeResult crash = new ReleaseSmokeResult("h"); + crash.finish("crash"); + assertEquals(3, crash.exitCode); + assertEquals("FAIL", crash.verdict); + } + + @Test + public void resultJsonContainsTheStructuredOutcomes() throws Exception { + ReleaseSmokeResult result = new ReleaseSmokeResult("Release Smoke Harness"); + result.tier = "QUICK"; + result.microbotVersion = "2.6.30"; + CheckOutcome bank = outcome(1, "bank", CheckStatus.FAILED, "Inventory not restored"); + bank.details.put("junkItem", "Feather#314"); + result.record(bank); + result.finish("completed"); + + File dir = folder.newFolder("release-e2e"); + TestResultWriter.write(result, dir.getAbsolutePath()); + + JsonObject json = new JsonParser().parse(new String(Files.readAllBytes(dir.toPath().resolve("result.json")), StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("FAIL", json.get("verdict").getAsString()); + assertEquals(1, json.get("exitCode").getAsInt()); + JsonArray results = json.getAsJsonArray("results"); + assertEquals(1, results.size()); + JsonObject first = results.get(0).getAsJsonObject(); + assertEquals("bank", first.get("id").getAsString()); + assertEquals("FAILED", first.get("status").getAsString()); + assertEquals(1500, first.get("durationMs").getAsLong()); + assertEquals("Feather#314", first.getAsJsonObject("details").get("junkItem").getAsString()); + JsonArray legacyChecks = json.getAsJsonArray("checks"); + assertEquals("bank", legacyChecks.get(0).getAsJsonObject().get("name").getAsString()); + } + + @Test + public void summaryIsAMarkdownTableWithEscapedDetails() throws Exception { + ReleaseSmokeResult result = new ReleaseSmokeResult("h"); + result.tier = "FULL"; + result.microbotVersion = "2.6.30"; + result.record(outcome(1, "boot", CheckStatus.PASSED, null)); + result.record(outcome(2, "world-hop", CheckStatus.FAILED, "a | b\nc")); + result.finish("completed"); + + Path file = ReleaseSmokeReport.write(result, folder.newFolder("out").toPath()); + String summary = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + + assertTrue(summary, summary.startsWith("## Microbot release E2E: FAIL (full tier)")); + assertTrue(summary, summary.contains("| 1 | `boot` boot description | quick | PASSED | 1.5s | |")); + assertTrue(summary, summary.contains("| 2 | `world-hop` world-hop description | quick | FAILED | 1.5s | a \\| b c |")); + assertTrue(summary, summary.contains("1 passed, 1 failed, 0 skipped, 0 not run")); + } + + @Test + public void durationsAreHumanReadable() { + assertEquals("0.4s", ReleaseSmokeReport.formatDuration(400)); + assertEquals("59.9s", ReleaseSmokeReport.formatDuration(59_900)); + assertEquals("2m05s", ReleaseSmokeReport.formatDuration(125_000)); + } + + @Test + public void hopTargetIsAPlainF2pWorldPreferringTheSameRegion() { + List worlds = List.of( + world(383, EnumSet.noneOf(WorldType.class), "Castle Wars - Free", 7, 219), + world(571, EnumSet.noneOf(WorldType.class), "PvP Arena (Legacy Duels)", 3, 58), + world(302, EnumSet.of(WorldType.MEMBERS), "-", 7, 20), + world(537, EnumSet.noneOf(WorldType.class), "-", 3, 90), + world(552, EnumSet.noneOf(WorldType.class), "-", 7, 102), + world(553, EnumSet.noneOf(WorldType.class), "-", 7, 152)); + assertEquals(552, ReleaseSmokeChecks.pickF2pWorld(worlds, 383)); + assertEquals(-1, ReleaseSmokeChecks.pickF2pWorld(List.of(worlds.get(0), worlds.get(1)), 383)); + } + + @Test + public void checkIdsParseFromCommasOrWhitespace() { + assertEquals(List.of("bank", "world-hop", "logout"), ReleaseSmokeHarnessPlugin.parseIds(" bank,world-hop logout ")); + assertTrue(ReleaseSmokeHarnessPlugin.parseIds("").isEmpty()); + } + + private static World world(int id, EnumSet types, String activity, int location, int players) { + return World.builder().id(id).types(types).activity(activity).location(location).players(players).address("x").build(); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java new file mode 100644 index 00000000000..e05d8d1aaa1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/InventorySetupSupplyLogoutTest.java @@ -0,0 +1,84 @@ +package net.runelite.client.plugins.microbot.util; + +import java.util.Collections; +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 InventorySetupSupplyLogoutTest { + @Test public void requiredInventoryItemMissingRequestsTerminalLogout() { + checkMissing(false, true, true, true); + } + + @Test public void requiredGearMissingRequestsTerminalLogout() { + checkMissing(true, true, true, true); + } + + @Test public void bankMirrorNotReadyDoesNotRequestLogout() { + checkMissing(false, false, false, true); + checkMissing(true, false, false, true); + } + + @Test public void existingInventoryCallerPausesWithoutLoggingOutOrCancelling() { + checkMissing(false, true, false, false); + } + + @Test public void existingGearCallerPausesWithoutLoggingOutOrCancelling() { + checkMissing(true, true, false, false); + } + + @Test public void defaultPolicyDoesNotPauseForAnUnreadyBankMirror() { + checkMissing(false, false, false, false); + checkMissing(true, false, false, false); + } + + private void checkMissing(boolean gear, boolean mirrorReady, boolean expectLogout, boolean optIn) { + boolean pausedBefore = Microbot.pauseAllScripts.getAndSet(false); + InventorySetup setup = mock(InventorySetup.class); + InventorySetupsItem row = new InventorySetupsItem(100, "Required supply", 1, true, + InventorySetupsStackCompareID.None, false, -1); + when(setup.getInventory()).thenReturn(gear ? Collections.emptyList() : Collections.singletonList(row)); + when(setup.getEquipment()).thenReturn(gear ? Collections.singletonList(row) : Collections.emptyList()); + Client client = mock(Client.class); + 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(client); + microbot.when(Microbot::getClientThread).thenReturn(thread); + bank.when(Rs2Bank::isOpen).thenReturn(true); + bank.when(() -> Rs2Bank.verifyBankMirrorAfterOpen(true, 0)).thenReturn(mirrorReady); + 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); + Rs2InventorySetup loader = new Rs2InventorySetup(setup, scheduler); + if (optIn) { + assertSame(loader, loader.withMissingSupplyPolicy(Rs2InventorySetup.MissingSupplyPolicy.LOGOUT_AND_STOP)); + } + assertFalse(gear ? loader.loadEquipment(false) : loader.loadInventory(false)); + player.verify(Rs2Player::logoutWithoutAutoLogin, times(expectLogout ? 1 : 0)); + verify(scheduler, times(expectLogout ? 1 : 0)).cancel(false); + assertEquals(!optIn && mirrorReady, Microbot.pauseAllScripts.get()); + } finally { + Microbot.pauseAllScripts.set(pausedBefore); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java new file mode 100644 index 00000000000..5cb341875d7 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/antiban/Rs2AntibanActionCooldownTest.java @@ -0,0 +1,62 @@ +package net.runelite.client.plugins.microbot.util.antiban; + +import net.runelite.client.plugins.microbot.util.antiban.enums.ActivityIntensity; +import net.runelite.client.plugins.microbot.util.antiban.enums.PlayStyle; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class Rs2AntibanActionCooldownTest { + + @Before + public void setUp() { + Rs2Antiban.resetAntibanSettings(true); + Rs2AntibanSettings.usePlayStyle = true; + Rs2AntibanSettings.actionCooldownChance = 1.0; + Rs2Antiban.setTIMEOUT(0); + } + + @After + public void tearDown() { + Rs2Antiban.resetAntibanSettings(true); + Rs2Antiban.setTIMEOUT(0); + } + + @Test + public void actionCooldownWithoutActivityDefaultsToIntensityPlayStyle() { + Rs2Antiban.actionCooldown(); + + PlayStyle expected = ActivityIntensity.EXTREME.getPlayStyle(); + assertSame(expected, Rs2Antiban.getPlayStyle()); + assertEquals(expected.getPrimaryTickInterval(), Rs2Antiban.getTIMEOUT()); + assertTrue(Rs2AntibanSettings.actionCooldownActive); + } + + @Test + public void actionCooldownWithoutActivityUsesRandomPlayStyleWhenRandomIntervalsEnabled() { + Rs2AntibanSettings.randomIntervals = true; + Rs2AntibanSettings.behavioralVariability = true; + Rs2AntibanSettings.nonLinearIntervals = true; + + Rs2Antiban.actionCooldown(); + + assertSame(PlayStyle.RANDOM, Rs2Antiban.getPlayStyle()); + assertTrue(Rs2Antiban.getTIMEOUT() >= 1); + assertTrue(Rs2AntibanSettings.actionCooldownActive); + } + + @Test + public void actionCooldownKeepsExistingPlayStyle() { + PlayStyle.CAUTIOUS.resetPlayStyle(); + Rs2Antiban.setPlayStyle(PlayStyle.CAUTIOUS); + + Rs2Antiban.actionCooldown(); + + assertSame(PlayStyle.CAUTIOUS, Rs2Antiban.getPlayStyle()); + assertEquals(PlayStyle.CAUTIOUS.getPrimaryTickInterval(), Rs2Antiban.getTIMEOUT()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java new file mode 100644 index 00000000000..a3fabadabf1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankNullPlayerLocationTest.java @@ -0,0 +1,54 @@ +package net.runelite.client.plugins.microbot.util.bank; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class Rs2BankNullPlayerLocationTest { + + private MockedStatic player; + + @Before + public void setUp() { + player = Mockito.mockStatic(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenReturn(null); + } + + @After + public void tearDown() { + player.close(); + } + + @Test + public void nearestBankLookupsReturnNoBankWithoutPlayerLocation() { + assertNull(Rs2Bank.getNearestBank()); + assertNull(Rs2Bank.getNearestBank(null)); + assertNull(Rs2Bank.getNearestBank(null, 20)); + assertNull(Rs2Bank.getNearestBankRoute(null)); + assertTrue(Rs2Bank.getPathToNearestBank().isEmpty()); + } + + @Test + public void isNearBankIsFalseWithoutPlayerLocation() { + assertFalse(Rs2Bank.isNearBank(10)); + assertFalse(Rs2Bank.isNearBank(BankLocation.GRAND_EXCHANGE, 10)); + } + + @Test + public void distanceBetweenIsUnreachableForNullEndpoint() { + WorldPoint point = new WorldPoint(3164, 3487, 0); + assertEquals(Integer.MAX_VALUE, Rs2Walker.getDistanceBetween(null, point)); + assertEquals(Integer.MAX_VALUE, Rs2Walker.getDistanceBetween(point, null)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java new file mode 100644 index 00000000000..837945106b1 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/coords/Rs2WorldPointNullPointTest.java @@ -0,0 +1,46 @@ +package net.runelite.client.plugins.microbot.util.coords; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +public class Rs2WorldPointNullPointTest +{ + private static final WorldPoint TARGET = new WorldPoint(3200, 3200, 0); + + @Test + public void pathToReturnsNullWhenWrappedPointIsNull() + { + Rs2WorldPoint point = new Rs2WorldPoint(null); + + assertNull(point.pathTo(TARGET)); + assertNull(point.pathTo(TARGET, true)); + } + + @Test + public void pathToReturnsNullWhenTargetIsNull() + { + assertNull(new Rs2WorldPoint(TARGET).pathTo(null)); + } + + @Test + public void distanceToPathIsUnreachableWhenWrappedPointIsNull() + { + assertEquals(Integer.MAX_VALUE, new Rs2WorldPoint(null).distanceToPath(TARGET)); + } + + @Test + public void objectMethodsTolerateNullWrappedPoint() + { + Rs2WorldPoint empty = new Rs2WorldPoint(null); + + assertEquals(new Rs2WorldPoint(null), empty); + assertNotEquals(new Rs2WorldPoint(TARGET), empty); + assertNotEquals(empty, new Rs2WorldPoint(TARGET)); + assertEquals(0, empty.hashCode()); + assertEquals("null", empty.toString()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java new file mode 100644 index 00000000000..932e0e53475 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/dialogues/Rs2DialogueClosingWidgetTest.java @@ -0,0 +1,142 @@ +package net.runelite.client.plugins.microbot.util.dialogues; + +import net.runelite.api.Client; +import net.runelite.api.widgets.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2DialogueClosingWidgetTest { + + private MockedStatic widgets; + private MockedStatic microbot; + private Client client; + + @Before + public void setUp() { + widgets = Mockito.mockStatic(Rs2Widget.class); + widgets.when(() -> Rs2Widget.isWidgetVisible(InterfaceID.DIALOG_OPTION, 1)).thenReturn(true); + + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> { + Callable callable = invocation.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + client = mock(Client.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + microbot.when(Microbot::getClient).thenReturn(client); + } + + @After + public void tearDown() { + microbot.close(); + widgets.close(); + } + + private void dialogOpenAtCheck(Widget atCheck, Widget atFetch) { + widgets.when(() -> Rs2Widget.getWidget(InterfaceID.DIALOG_OPTION, 1)).thenReturn(atCheck); + when(client.getWidget(InterfaceID.DIALOG_OPTION, 1)).thenReturn(atFetch); + } + + private void dialogOpen(Widget container) { + dialogOpenAtCheck(container, container); + } + + private static Widget textWidget(String text) { + Widget widget = mock(Widget.class); + when(widget.getText()).thenReturn(text); + return widget; + } + + private static Widget optionContainer(Widget... children) { + Widget container = mock(Widget.class); + when(container.getDynamicChildren()).thenReturn(children); + return container; + } + + @Test + public void getQuestionReturnsNullWhenDialogClosesAfterCheck() { + Widget open = optionContainer(textWidget("Pay 200 coins?")); + dialogOpenAtCheck(open, null); + + assertNull(Rs2Dialogue.getQuestion()); + } + + @Test + public void hasQuestionReturnsFalseWhenDialogClosesAfterCheck() { + Widget open = optionContainer(textWidget("Pay 200 coins?")); + dialogOpenAtCheck(open, null); + + assertFalse(Rs2Dialogue.hasQuestion("pay")); + } + + @Test + public void getQuestionReturnsNullWhenChildrenAreEmptyOrNull() { + Widget empty = optionContainer(); + dialogOpen(empty); + assertNull(Rs2Dialogue.getQuestion()); + + Widget nullFirst = optionContainer((Widget) null); + dialogOpen(nullFirst); + assertNull(Rs2Dialogue.getQuestion()); + + Widget nullText = optionContainer(textWidget(null)); + dialogOpen(nullText); + assertNull(Rs2Dialogue.getQuestion()); + } + + @Test + public void getQuestionStripsColourTags() { + Widget open = optionContainer(textWidget("Pay 200 coins?")); + dialogOpen(open); + + assertEquals("Pay 200 coins?", Rs2Dialogue.getQuestion()); + assertTrue(Rs2Dialogue.hasQuestion("200 coins")); + } + + @Test + public void getDialogueOptionsSkipsMissingChildrenAndText() { + Widget yes = textWidget("Yes"); + Widget container = optionContainer(textWidget("Pay?"), null, textWidget(null), textWidget(" "), yes); + dialogOpen(container); + + List options = Rs2Dialogue.getDialogueOptions(); + assertEquals(1, options.size()); + assertEquals(yes, options.get(0)); + } + + @Test + public void getDialogueOptionsReturnsEmptyWhenDialogClosesAfterCheck() { + Widget open = optionContainer(textWidget("Pay?"), textWidget("Yes")); + dialogOpenAtCheck(open, null); + + assertTrue(Rs2Dialogue.getDialogueOptions().isEmpty()); + } + + @Test + public void hasDialogueOptionTitleReturnsFalseWhenTitleTextMissing() { + Widget container = optionContainer(textWidget(null), textWidget("Yes")); + dialogOpen(container); + + assertFalse(Rs2Dialogue.hasDialogueOptionTitle("Pay")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java new file mode 100644 index 00000000000..1dfb32e7c97 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/gameobject/Rs2GameObjectNullAnchorTest.java @@ -0,0 +1,75 @@ +package net.runelite.client.plugins.microbot.util.gameobject; + +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.api.playerstate.Rs2PlayerStateCache; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +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 Rs2GameObjectNullAnchorTest { + + private MockedStatic microbot; + private MockedStatic player; + private Client client; + + @Before + public void setUp() { + client = mock(Client.class); + when(client.getLocalPlayer()).thenReturn(mock(Player.class)); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + Rs2PlayerStateCache stateCache = mock(Rs2PlayerStateCache.class); + when(stateCache.getLocalPlayerWorldView()).thenReturn(mock(WorldView.class)); + microbot.when(Microbot::getRs2PlayerStateCache).thenReturn(stateCache); + player = Mockito.mockStatic(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenReturn(null); + } + + @After + public void tearDown() { + player.close(); + microbot.close(); + } + + @Test + public void findBankReturnsNullWhenPlayerLocationUnknown() { + assertNull(Rs2GameObject.findBank()); + verify(client, never()).getTopLevelWorldView(); + } + + @Test + public void worldPointQueriesReturnEmptyForNullAnchor() { + WorldPoint anchor = null; + assertTrue(Rs2GameObject.getTileObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getGameObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getGroundObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getWallObjects(o -> true, anchor, 10).isEmpty()); + assertTrue(Rs2GameObject.getDecorativeObjects(o -> true, anchor, 10).isEmpty()); + verify(client, never()).getTopLevelWorldView(); + } + + @Test + public void singleObjectQueriesReturnNullForNullAnchor() { + WorldPoint anchor = null; + assertNull(Rs2GameObject.getTileObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getGameObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getGroundObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getWallObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getDecorativeObject(o -> true, anchor, 10)); + assertNull(Rs2GameObject.getGameObject(1234, 10)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java new file mode 100644 index 00000000000..4cd9f777b8f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicBlightedSacksTest.java @@ -0,0 +1,53 @@ +package net.runelite.client.plugins.microbot.util.magic; + +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import java.util.HashMap; +import static org.junit.Assert.*; + +public class Rs2MagicBlightedSacksTest { + @Test public void oneSackPaysForOneWholeIceCast() { + assertEquals(0, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_RUSH, 1, 1, true)); + assertEquals(0, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BURST, 2, 2, true)); + assertEquals(3, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BLITZ, 5, 2, true)); + assertEquals(0, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BARRAGE, 1, 3, true)); + } + @Test public void sacksCannotCastOutsideWildernessOrOtherSpells() { + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BARRAGE, 1, 1, false)); + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.BLOOD_BARRAGE, 1, 1, true)); + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.WATER_STRIKE, 1, 1, true)); + assertEquals(1, Rs2Magic.runeCastsAfterIceSacks(Rs2CombatSpells.ICE_BURST, 1, -1, true)); + } + @Test public void runeChecksHonorInventoryAndBankFilters() { + try (MockedStatic pvp = Mockito.mockStatic(Rs2Pvp.class); + MockedStatic inventory = Mockito.mockStatic(Rs2Inventory.class); + MockedStatic bank = Mockito.mockStatic(Rs2Bank.class); + MockedStatic magic = Mockito.mockStatic(Rs2Magic.class, Mockito.CALLS_REAL_METHODS)) { + pvp.when(Rs2Pvp::isInWilderness).thenReturn(true); + inventory.when(() -> Rs2Inventory.itemQuantity(ItemID.BLIGHTED_SACK_ICEBARRAGE)).thenReturn(2); + bank.when(() -> Rs2Bank.count(ItemID.BLIGHTED_SACK_ICEBARRAGE)).thenReturn(3); + magic.when(() -> Rs2Magic.getRunes(Mockito.any(RuneFilter.class))).thenAnswer(call -> new HashMap<>()); + + RuneFilter inventoryOnly = RuneFilter.builder().includeBank(false).build(); + RuneFilter bankOnly = RuneFilter.builder().includeInventory(false).includeBank(true).build(); + RuneFilter both = RuneFilter.builder().includeBank(true).build(); + assertEquals(Rs2CombatSpells.ICE_BARRAGE.getRequiredRunes(2), + Rs2Magic.getMissingRunes(Rs2CombatSpells.ICE_BARRAGE, 4, inventoryOnly)); + assertEquals(Rs2CombatSpells.ICE_BARRAGE.getRequiredRunes(1), + Rs2Magic.getMissingRunes(Rs2CombatSpells.ICE_BARRAGE, 4, bankOnly)); + assertTrue(Rs2Magic.hasRequiredRunes(Rs2CombatSpells.ICE_BARRAGE, 4, both)); + assertFalse(Rs2Magic.hasRequiredRunes(Rs2CombatSpells.ICE_BARRAGE, 4, inventoryOnly)); + + pvp.when(Rs2Pvp::isInWilderness).thenReturn(false); + assertEquals(Rs2CombatSpells.ICE_BARRAGE.getRequiredRunes(4), + Rs2Magic.getMissingRunes(Rs2CombatSpells.ICE_BARRAGE, 4, both)); + assertEquals(Rs2CombatSpells.BLOOD_BARRAGE.getRequiredRunes(4), + Rs2Magic.getMissingRunes(Rs2CombatSpells.BLOOD_BARRAGE, 4, both)); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java new file mode 100644 index 00000000000..aab11c1d58e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/magic/Rs2MagicMissingSpellWidgetTest.java @@ -0,0 +1,110 @@ +package net.runelite.client.plugins.microbot.util.magic; + +import net.runelite.api.Client; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.awt.Rectangle; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2MagicMissingSpellWidgetTest { + + private MockedStatic widgets; + private MockedStatic microbot; + private MockedStatic tabs; + private MockedStatic magic; + + @Before + public void setUp() { + widgets = Mockito.mockStatic(Rs2Widget.class); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(mock(Client.class)); + tabs = Mockito.mockStatic(Rs2Tab.class); + magic = Mockito.mockStatic(Rs2Magic.class, Mockito.CALLS_REAL_METHODS); + magic.when(() -> Rs2Magic.canCast(any(MagicAction.class))).thenReturn(true); + } + + @After + public void tearDown() { + magic.close(); + tabs.close(); + microbot.close(); + widgets.close(); + } + + private void spellbookWithoutMatch() { + Widget root = mock(Widget.class); + when(root.getStaticChildren()).thenReturn(new Widget[]{mock(Widget.class)}); + widgets.when(() -> Rs2Widget.getWidget(218, 0)).thenReturn(root); + widgets.when(() -> Rs2Widget.findWidget(anyString(), anyList())).thenReturn(null); + } + + @Test + public void widgetLookupReturnsSentinelsWhenSpellWidgetMissing() { + spellbookWithoutMatch(); + assertEquals(-1, MagicAction.VARROCK_TELEPORT.getWidgetId()); + assertNull(MagicAction.VARROCK_TELEPORT.getActions()); + } + + @Test + public void widgetLookupReturnsSentinelsWhenSpellbookNotLoaded() { + widgets.when(() -> Rs2Widget.getWidget(218, 0)).thenReturn(null); + assertEquals(-1, MagicAction.VARROCK_TELEPORT.getWidgetId()); + assertNull(MagicAction.VARROCK_TELEPORT.getActions()); + } + + @Test + public void castFailsInsteadOfThrowingWhenSpellWidgetMissing() { + spellbookWithoutMatch(); + assertFalse(Rs2Magic.cast(MagicAction.VARROCK_TELEPORT, "cast", 1)); + microbot.verify(() -> Microbot.doInvoke(any(), any(Rectangle.class)), Mockito.never()); + } + + @Test + public void castFailsWhenResolvedWidgetDisappears() { + Widget root = mock(Widget.class); + Widget spell = mock(Widget.class); + when(spell.getId()).thenReturn(14286870); + when(root.getStaticChildren()).thenReturn(new Widget[]{spell}); + widgets.when(() -> Rs2Widget.getWidget(218, 0)).thenReturn(root); + widgets.when(() -> Rs2Widget.findWidget(eq("Varrock Teleport"), anyList())).thenReturn(spell); + widgets.when(() -> Rs2Widget.getWidget(anyInt())).thenReturn(null); + assertFalse(Rs2Magic.cast(MagicAction.VARROCK_TELEPORT, "cast", 1)); + microbot.verify(() -> Microbot.doInvoke(any(), any(Rectangle.class)), Mockito.never()); + } + + @Test + public void widgetLookupFallsBackToSpriteWhenNameDoesNotMatch() { + spellbookWithoutMatch(); + Widget spellList = mock(Widget.class); + Widget other = mock(Widget.class); + when(other.getSpriteId()).thenReturn(-1); + Widget spell = mock(Widget.class); + when(spell.getSpriteId()).thenReturn(MagicAction.VARROCK_TELEPORT.getSprite()); + when(spell.getId()).thenReturn(14286870); + when(spell.getActions()).thenReturn(new String[]{"Cast"}); + when(spellList.getStaticChildren()).thenReturn(new Widget[]{other, spell}); + widgets.when(() -> Rs2Widget.getWidget(218, 3)).thenReturn(spellList); + assertEquals(14286870, MagicAction.VARROCK_TELEPORT.getWidgetId()); + assertEquals("Cast", MagicAction.VARROCK_TELEPORT.getActions()[0]); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java new file mode 100644 index 00000000000..1be06db72ec --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelperCanvasBoundsTest.java @@ -0,0 +1,13 @@ +package net.runelite.client.plugins.microbot.util.misc; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; + +public class Rs2UiHelperCanvasBoundsTest { + + @Test + public void nullRectangleIsNotWithinCanvas() { + assertFalse(Rs2UiHelper.isRectangleWithinCanvas(null)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java new file mode 100644 index 00000000000..4da36e96600 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/player/Rs2PlayerNullLocalPlayerTest.java @@ -0,0 +1,71 @@ +package net.runelite.client.plugins.microbot.util.player; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import net.runelite.api.Client; +import net.runelite.api.Scene; +import net.runelite.api.WorldView; +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.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +public class Rs2PlayerNullLocalPlayerTest { + private final Map original = new HashMap<>(); + private Scene scene; + + @Before + public void setUp() throws Exception { + Client client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + WorldView worldView = mock(WorldView.class); + scene = mock(Scene.class); + replace("client", client); + replace("clientThread", clientThread); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(invocation -> + Optional.ofNullable(((Callable) invocation.getArgument(0)).call())); + when(client.getLocalPlayer()).thenReturn(null); + when(client.getTopLevelWorldView()).thenReturn(worldView); + when(worldView.getScene()).thenReturn(scene); + } + + private void replace(String name, Object value) throws Exception { + Field field = Microbot.class.getDeclaredField(name); + field.setAccessible(true); + original.put(name, field.get(null)); + field.set(null, value); + } + + @After + public void tearDown() throws Exception { + for (Map.Entry entry : original.entrySet()) { + Field field = Microbot.class.getDeclaredField(entry.getKey()); + field.setAccessible(true); + field.set(null, entry.getValue()); + } + } + + @Test + public void worldLocationIsNullWithoutLocalPlayer() { + when(scene.isInstance()).thenReturn(false); + assertNull(Rs2Player.getWorldLocation_Internal()); + when(scene.isInstance()).thenReturn(true); + assertNull(Rs2Player.getWorldLocation_Internal()); + } + + @Test + public void accessorsReturnNoPlayerDefaultsWithoutLocalPlayer() { + assertEquals(-1, Rs2Player.getPoseAnimation()); + assertEquals(0, Rs2Player.getCombatLevel()); + assertNull(Rs2Player.getLocalLocation()); + assertEquals(-1, Rs2Player.getGraphicId()); + assertFalse(Rs2Player.hasSpotAnimation(1)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java new file mode 100644 index 00000000000..56adb53e6f6 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginLockoutTest.java @@ -0,0 +1,106 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Test; +import static org.junit.Assert.*; + +public class LoginLockoutTest { + private final AtomicLong clock = new AtomicLong(1_000L); + + private LoginLockout lockout(double random) { + return new LoginLockout(clock::get, () -> random); + } + + @Test public void recognisesOnlyTheTooManyAttemptsLoginIndex() { + assertTrue(LoginLockout.isLockoutLoginIndex(33)); + for (int index : new int[] {0, 2, 3, 4, 9, 10, 14, 24, 32, 34}) { + assertFalse(LoginLockout.isLockoutLoginIndex(index)); + } + } + + @Test public void lockoutStartsCooldownThatExpiresWithTheClock() { + LoginLockout lockout = lockout(0.5); + assertFalse(lockout.isActive()); + assertTrue(lockout.recordLockout()); + long first = TimeUnit.MINUTES.toMillis(5); + assertTrue(lockout.isActive()); + assertEquals(first, lockout.remainingMillis()); + clock.addAndGet(first - 1); + assertTrue(lockout.isActive()); + clock.incrementAndGet(); + assertFalse(lockout.isActive()); + assertEquals(0L, lockout.remainingMillis()); + } + + @Test public void repeatedDetectionWhileActiveDoesNotExtendOrEscalate() { + LoginLockout lockout = lockout(0.5); + assertTrue(lockout.recordLockout()); + long remaining = lockout.remainingMillis(); + clock.addAndGet(1_000L); + assertFalse(lockout.recordLockout()); + assertEquals(remaining - 1_000L, lockout.remainingMillis()); + assertEquals(1, lockout.getConsecutiveLockouts()); + } + + @Test public void consecutiveLockoutsEscalateAndCap() { + LoginLockout lockout = lockout(0.5); + long[] expectedMinutes = {5, 10, 20, 30, 30}; + for (long minutes : expectedMinutes) { + assertTrue(lockout.recordLockout()); + long duration = TimeUnit.MINUTES.toMillis(minutes); + assertEquals(duration, lockout.remainingMillis()); + lockout.recordScreenCleared(); + clock.addAndGet(duration); + } + } + + @Test public void lockoutScreenThatOutlivesItsCooldownIsNotCountedAgain() { + LoginLockout lockout = lockout(0.5); + assertTrue(lockout.recordLockout()); + clock.addAndGet(TimeUnit.MINUTES.toMillis(5)); + assertFalse(lockout.isActive()); + assertFalse(lockout.recordLockout()); + assertFalse(lockout.isActive()); + assertEquals(1, lockout.getConsecutiveLockouts()); + lockout.recordScreenCleared(); + assertTrue(lockout.recordLockout()); + assertEquals(2, lockout.getConsecutiveLockouts()); + assertEquals(TimeUnit.MINUTES.toMillis(10), lockout.remainingMillis()); + } + + @Test public void jitterStaysWithinBounds() { + long base = TimeUnit.MINUTES.toMillis(5); + LoginLockout low = lockout(0.0); + low.recordLockout(); + assertEquals(Math.round(base * 0.85), low.remainingMillis()); + LoginLockout high = lockout(0.999999); + high.recordLockout(); + assertTrue(high.remainingMillis() <= Math.round(base * 1.15)); + assertTrue(high.remainingMillis() > base); + } + + @Test public void successfulLoginClearsCooldownAndEscalation() { + LoginLockout lockout = lockout(0.5); + lockout.recordLockout(); + lockout.recordScreenCleared(); + clock.addAndGet(TimeUnit.MINUTES.toMillis(5)); + lockout.recordLockout(); + lockout.recordSuccessfulLogin(); + assertFalse(lockout.isActive()); + assertEquals(0, lockout.getConsecutiveLockouts()); + assertTrue(lockout.recordLockout()); + assertEquals(TimeUnit.MINUTES.toMillis(5), lockout.remainingMillis()); + } + + @Test public void dismissIsRateLimited() { + LoginLockout lockout = lockout(0.5); + lockout.recordLockout(); + assertTrue(lockout.tryClaimDismiss()); + assertFalse(lockout.tryClaimDismiss()); + clock.addAndGet(LoginLockout.DISMISS_INTERVAL_MILLIS - 1); + assertFalse(lockout.tryClaimDismiss()); + clock.incrementAndGet(); + assertTrue(lockout.tryClaimDismiss()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java new file mode 100644 index 00000000000..62cf7d310d0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/security/LoginManagerLockoutTest.java @@ -0,0 +1,105 @@ +package net.runelite.client.plugins.microbot.util.security; + +import java.util.concurrent.atomic.AtomicInteger; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.mouse.Mouse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +public class LoginManagerLockoutTest { + private final AtomicInteger loginIndex = new AtomicInteger(LoginLockout.LOGIN_INDEX_TOO_MANY_ATTEMPTS); + private Client client; + private Mouse mouse; + + @Before public void setUp() { + LoginLockout.getInstance().recordSuccessfulLogin(); + client = mock(Client.class); + mouse = mock(Mouse.class); + when(client.getGameState()).thenReturn(GameState.LOGIN_SCREEN); + when(client.getLoginIndex()).thenAnswer(call -> loginIndex.get()); + when(client.getCanvasWidth()).thenReturn(804); + } + + @After public void tearDown() { + LoginLockout.getInstance().recordSuccessfulLogin(); + } + + @Test public void lockoutScreenPressesBackAndNeverSubmits() { + try (var microbot = mockStatic(Microbot.class); + var keyboard = mockStatic(Rs2Keyboard.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.login("user", "", 383)); + + verify(mouse, times(1)).click(365, 326); + verify(client, never()).setUsername(anyString()); + verify(client, never()).setPassword(anyString()); + verify(client, never()).changeWorld(any()); + keyboard.verifyNoInteractions(); + assertTrue(LoginLockout.getInstance().isActive()); + } + } + + @Test public void cooldownKeepsRefusingAfterTheScreenIsDismissed() { + try (var microbot = mockStatic(Microbot.class); + var keyboard = mockStatic(Rs2Keyboard.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.login("user", "", 383)); + loginIndex.set(2); + assertFalse(LoginManager.isLoginLockoutScreen()); + assertTrue(LoginManager.checkLoginLockout()); + assertFalse(LoginManager.login("user", "", 383)); + + verify(mouse, times(1)).click(anyInt(), anyInt()); + verify(client, never()).setUsername(anyString()); + keyboard.verifyNoInteractions(); + } + } + + @Test public void repeatedPollingOnLockoutScreenDoesNotSpamClicks() { + try (var microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + for (int i = 0; i < 5; i++) { + assertTrue(LoginManager.checkLoginLockout()); + } + + verify(mouse, times(1)).click(anyInt(), anyInt()); + assertEquals(1, LoginLockout.getInstance().getConsecutiveLockouts()); + } + } + + @Test public void normalLoginScreenIsNotTreatedAsLockout() { + loginIndex.set(2); + try (var microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.isLoginLockoutScreen()); + assertFalse(LoginManager.checkLoginLockout()); + verifyNoInteractions(mouse); + } + } + + @Test public void lockoutIndexOutsideLoginScreenIsIgnored() { + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + try (var microbot = mockStatic(Microbot.class)) { + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getMouse).thenReturn(mouse); + + assertFalse(LoginManager.checkLoginLockout()); + verifyNoInteractions(mouse); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java new file mode 100644 index 00000000000..14e7bce65ed --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerInteractingActorThreadTest.java @@ -0,0 +1,106 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Actor; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WalkerInteractingActorThreadTest { + + private MockedStatic microbot; + private MockedStatic pathApi; + private MockedStatic player; + private final AtomicBoolean onClientThread = new AtomicBoolean(false); + private ClientThread clientThread; + + @Before + public void setUp() { + 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::getClientThread).thenReturn(clientThread); + + Rs2ActiveRouteStatus status = mock(Rs2ActiveRouteStatus.class); + when(status.isPresent()).thenReturn(true); + when(status.getWalkablePath()).thenReturn(Arrays.asList( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3201, 3200, 0), + new WorldPoint(3202, 3200, 0))); + pathApi = Mockito.mockStatic(Rs2PathApi.class); + pathApi.when(Rs2PathApi::getActiveRouteStatus).thenReturn(status); + + player = Mockito.mockStatic(Rs2Player.class); + } + + @After + public void tearDown() { + player.close(); + pathApi.close(); + microbot.close(); + } + + private Actor clientThreadOnlyActor(WorldPoint location) { + Actor actor = mock(Actor.class); + when(actor.getWorldLocation()).thenAnswer(inv -> { + if (!onClientThread.get()) { + throw new IllegalStateException("must be called on client thread"); + } + return location; + }); + return actor; + } + + private static boolean invoke() throws Exception { + Method method = Rs2Walker.class.getDeclaredMethod("interactingActorNearWalkablePath"); + method.setAccessible(true); + return (boolean) method.invoke(null); + } + + @Test + public void readsInteractingActorLocationOnClientThread() throws Exception { + Actor actor = clientThreadOnlyActor(new WorldPoint(3201, 3202, 0)); + player.when(Rs2Player::getInteracting).thenReturn(actor); + assertTrue(invoke()); + } + + @Test + public void actorFarFromPathIsNotNear() throws Exception { + Actor actor = clientThreadOnlyActor(new WorldPoint(3220, 3220, 0)); + player.when(Rs2Player::getInteracting).thenReturn(actor); + assertFalse(invoke()); + } + + @Test + public void emptyClientThreadResultIsTreatedAsNotNear() throws Exception { + Actor actor = clientThreadOnlyActor(new WorldPoint(3201, 3202, 0)); + player.when(Rs2Player::getInteracting).thenReturn(actor); + Mockito.doReturn(Optional.empty()).when(clientThread).runOnClientThreadOptional(any()); + assertFalse(invoke()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java new file mode 100644 index 00000000000..459b172664e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalExecutorTest.java @@ -0,0 +1,282 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.Scene; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +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.door.Rs2DoorDetection; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Callable; + +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.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TransportRefusalExecutorTest +{ + private static final WorldPoint ORIGIN = new WorldPoint(3222, 3218, 0); + private static final WorldPoint FALLBACK_ORIGIN = new WorldPoint(3222, 3220, 0); + private static final WorldPoint DESTINATION = new WorldPoint(3222, 9618, 0); + + private final List> statics = new ArrayList<>(); + private final List learnedEdges = new ArrayList<>(); + private PathfinderConfig config; + private WorldPoint playerLocation; + private boolean spiritTreeTravelEnabled; + private boolean originReachable; + private int travelClicks; + private Rs2PathApi.ActiveTransportSelection selection; + + @Before + public void setUp() + { + playerLocation = ORIGIN; + spiritTreeTravelEnabled = true; + originReachable = true; + Rs2WalkerTransports.withdrawWalkScopedTransportBlocks(); + } + + @After + public void tearDown() + { + Rs2WalkerTransports.withdrawWalkScopedTransportBlocks(); + for (int i = statics.size() - 1; i >= 0; i--) + { + statics.get(i).close(); + } + statics.clear(); + } + + @Test + public void spiritTreeThatClicksTravelButCannotPickTheDestinationIsStruckAndReplanned() throws Exception + { + Transport spiritTree = new Transport(ORIGIN, DESTINATION, "Tree Gnome Village", + TransportType.SPIRIT_TREE, true, "Travel", "Spirit tree", 1293, 1); + install(spiritTree); + assertEquals(List.of(ORIGIN, DESTINATION), TransportRefusalStrikeOutTest.plan(config, ORIGIN, DESTINATION)); + + Rs2WalkerTransports.DispatchResult first = dispatch(); + assertFalse(first.handled); + assertEquals(TransportDispatchTrace.Verdict.REFUSED_AFTER_INTERACTION, first.verdict); + assertFalse(first.struckOut); + assertTrue("Travel was clicked", travelClicks > 0); + assertEquals(1, Rs2WalkerTransports.transportRefusalStrikes(ORIGIN, DESTINATION)); + + Rs2WalkerTransports.DispatchResult second = dispatch(); + assertTrue(second.struckOut); + assertEquals(List.of(edge(ORIGIN, DESTINATION)), learnedEdges); + assertReplanAvoidsRefusedEdge(); + } + + @Test + public void recoveryOriginUnreachableBailIsStruckAfterRepeatedFallThroughs() throws Exception + { + Transport door = new Transport(ORIGIN, DESTINATION, "Zanaris shed", + TransportType.TRANSPORT, true, "Open", "Door", 2406, 1); + install(door); + originReachable = false; + playerLocation = new WorldPoint(ORIGIN.getX() - 1, ORIGIN.getY(), 0); + List path = List.of(ORIGIN, DESTINATION); + + for (int pass = 1; pass < TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT; pass++) + { + assertFalse("pass " + pass + " must fall through", Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + assertEquals(pass, Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + } + assertTrue(learnedEdges.isEmpty()); + + assertTrue("third fall-through strikes the edge out", Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + assertEquals(List.of(edge(ORIGIN, DESTINATION)), learnedEdges); + assertReplanAvoidsRefusedEdge(); + } + + @Test + public void recoveryDispatchClassifiesTheOriginUnreachableBailAsARefusal() throws Exception + { + Transport door = new Transport(ORIGIN, DESTINATION, "Zanaris shed", + TransportType.TRANSPORT, true, "Open", "Door", 2406, 1); + install(door); + originReachable = false; + + Rs2WalkerTransports.DispatchResult result = dispatch(); + assertEquals(TransportDispatchTrace.Verdict.REFUSED_WITHOUT_INTERACTION, result.verdict); + assertEquals(TransportDispatchTrace.Refusal.ORIGIN_UNREACHABLE, result.trace.refusal()); + assertEquals("a fall-through outside recovery is not counted", 0, + Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + } + + @Test + public void spiritTreeTravelSwitchedOffIsADeliberateSkipThatNeverStrikes() throws Exception + { + Transport spiritTree = new Transport(ORIGIN, DESTINATION, "Tree Gnome Village", + TransportType.SPIRIT_TREE, true, "Travel", "Spirit tree", 1293, 1); + install(spiritTree); + spiritTreeTravelEnabled = false; + List path = List.of(ORIGIN, DESTINATION); + + for (int pass = 0; pass < 6; pass++) + { + assertFalse(Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + } + assertEquals(0, travelClicks); + assertEquals(0, Rs2WalkerTransports.transportRefusalStrikes(ORIGIN, DESTINATION)); + assertEquals(0, Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + assertTrue(learnedEdges.isEmpty()); + assertEquals(TransportDispatchTrace.DeliberateSkip.SPIRIT_TREE_TRAVEL_DISABLED, dispatch().trace.deliberateSkip()); + } + + @Test + public void planeMismatchIsADeliberateSkipThatNeverStrikes() throws Exception + { + Transport door = new Transport(ORIGIN, DESTINATION, "Zanaris shed", + TransportType.TRANSPORT, true, "Open", "Door", 2406, 1); + install(door); + originReachable = false; + playerLocation = new WorldPoint(ORIGIN.getX(), ORIGIN.getY(), 1); + List path = List.of(ORIGIN, DESTINATION); + + for (int pass = 0; pass < 6; pass++) + { + assertFalse(Rs2WalkerTransports.dispatchRecoveryTransport(path, 0)); + } + assertEquals(0, Rs2WalkerTransports.transportFallThroughs(ORIGIN, DESTINATION)); + assertTrue(learnedEdges.isEmpty()); + assertEquals(TransportDispatchTrace.Verdict.DELIBERATE_SKIP, dispatch().verdict); + } + + private Rs2WalkerTransports.DispatchResult dispatch() + { + return Rs2WalkerTransports.dispatchSelectedTransport(List.of(ORIGIN, DESTINATION), 0, selection, false); + } + + private void assertReplanAvoidsRefusedEdge() + { + List after = TransportRefusalStrikeOutTest.plan(config, ORIGIN, DESTINATION); + assertEquals(DESTINATION, after.get(after.size() - 1)); + assertTrue("replan must board the fallback transport", after.contains(FALLBACK_ORIGIN)); + for (int i = 0; i + 1 < after.size(); i++) + { + assertFalse("replan must not reuse the refused edge", + after.get(i).equals(ORIGIN) && after.get(i + 1).equals(DESTINATION)); + } + } + + private static String edge(WorldPoint origin, WorldPoint destination) + { + return origin + "->" + destination; + } + + private void install(Transport refused) throws Exception + { + Transport fallback = new Transport(FALLBACK_ORIGIN, DESTINATION, "fallback", TransportType.TRANSPORT, false, + "Climb-down", "Ladder", 1002, 5); + Map> catalog = new HashMap<>(); + catalog.put(ORIGIN, Set.of(refused)); + catalog.put(FALLBACK_ORIGIN, Set.of(fallback)); + config = TransportRefusalStrikeOutTest.configWithTransports(catalog); + selection = selectionFor(refused); + + Client client = mock(Client.class); + WorldView worldView = mock(WorldView.class); + Scene scene = mock(Scene.class); + when(client.getTopLevelWorldView()).thenReturn(worldView); + when(client.isClientThread()).thenReturn(true); + when(worldView.getScene()).thenReturn(scene); + when(worldView.contains(any(WorldPoint.class))).thenReturn(true); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> { + Callable callable = inv.getArgument(0); + return Optional.ofNullable(callable.call()); + }); + + MockedStatic microbot = open(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + + MockedStatic player = open(Rs2Player.class); + player.when(Rs2Player::getWorldLocation).thenAnswer(inv -> playerLocation); + + MockedStatic pathApi = open(Rs2PathApi.class); + pathApi.when(() -> Rs2PathApi.getActiveTransportSelection(any(), anyInt())) + .thenAnswer(inv -> Optional.of(selection)); + pathApi.when(Rs2PathApi::isSpiritTreeTravelEnabled).thenAnswer(inv -> spiritTreeTravelEnabled); + pathApi.when(() -> Rs2PathApi.learnBlockedEdge(any(), any(), anyString())).thenAnswer(inv -> { + WorldPoint origin = inv.getArgument(0); + WorldPoint destination = inv.getArgument(1); + learnedEdges.add(edge(origin, destination)); + return config.learnBlockedEdge(origin, destination, inv.getArgument(2)); + }); + pathApi.when(() -> Rs2PathApi.unlearnBlockedEdge(any(), any(), anyString())).thenAnswer(inv -> + config.unlearnBlockedEdge(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2))); + + open(Rs2LeaguesTransport.class); + open(Rs2Widget.class); + open(Rs2DoorDetection.class); + + MockedStatic tile = open(Rs2Tile.class); + tile.when(() -> Rs2Tile.isTileReachable(any(WorldPoint.class))).thenAnswer(inv -> originReachable); + + GameObject sceneObject = mock(GameObject.class); + when(sceneObject.getId()).thenReturn(refused.getObjectId()); + when(sceneObject.getWorldLocation()).thenReturn(ORIGIN); + MockedStatic gameObjects = open(Rs2GameObject.class); + gameObjects.when(() -> Rs2GameObject.findObjectById(anyInt())).thenReturn(sceneObject); + gameObjects.when(() -> Rs2GameObject.interact(any(TileObject.class), eq("Travel"))).thenAnswer(inv -> { + travelClicks++; + return true; + }); + gameObjects.when(() -> Rs2GameObject.getAll(any(), any(WorldPoint.class), anyInt())).thenAnswer(inv -> + refused.getType() == TransportType.SPIRIT_TREE ? List.of() : List.of(sceneObject)); + } + + private MockedStatic open(Class type) + { + MockedStatic mocked = Mockito.mockStatic(type); + statics.add(mocked); + return mocked; + } + + private static Rs2PathApi.ActiveTransportSelection selectionFor(Transport transport) throws Exception + { + Method toEdge = Rs2PathApi.class.getDeclaredMethod("toTransportEdge", Transport.class); + toEdge.setAccessible(true); + Object edge = toEdge.invoke(null, transport); + Constructor ctor = Rs2PathApi.ActiveTransportSelection.class + .getDeclaredConstructor(int.class, edge.getClass(), Transport.class); + ctor.setAccessible(true); + return ctor.newInstance(0, edge, transport); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java new file mode 100644 index 00000000000..c4ee3bfa83e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRefusalStrikeOutTest.java @@ -0,0 +1,215 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace.DeliberateSkip; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace.Refusal; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportDispatchTrace.Verdict; +import net.runelite.client.plugins.microbot.util.walker.transport.TransportRefusalLedger; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TransportRefusalStrikeOutTest +{ + private static final WorldPoint ORIGIN = new WorldPoint(3222, 3218, 0); + private static final WorldPoint FALLBACK_ORIGIN = new WorldPoint(3222, 3220, 0); + private static final WorldPoint DESTINATION = new WorldPoint(3222, 9618, 0); + + private static SplitFlagMap collisionMap; + + @BeforeClass + public static void loadMap() + { + collisionMap = sharedCollisionMap(); + } + + static synchronized SplitFlagMap sharedCollisionMap() + { + if (collisionMap == null) + { + collisionMap = SplitFlagMap.fromResources(); + } + return collisionMap; + } + + @Test + public void secondRefusalStrikesOut() + { + TransportRefusalLedger ledger = new TransportRefusalLedger(); + assertFalse(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertTrue(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertFalse("only the exact refusal that reaches the limit strikes out", + TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertEquals(0, ledger.registerRefusal(null, DESTINATION)); + } + + @Test + public void interactionRefusalRequiresThePlayerToStillBeAtTheOrigin() + { + WorldPoint door = new WorldPoint(3201, 3169, 0); + WorldPoint inside = new WorldPoint(3202, 3169, 0); + assertTrue(TransportRefusalLedger.isStillAtOrigin(door, door, inside)); + assertTrue(TransportRefusalLedger.isStillAtOrigin(ORIGIN, ORIGIN, DESTINATION)); + + assertFalse("already across the edge", + TransportRefusalLedger.isStillAtOrigin(inside, door, inside)); + assertFalse("walked away from the origin", + TransportRefusalLedger.isStillAtOrigin(new WorldPoint(3198, 3169, 0), door, inside)); + assertFalse("plane mismatch", + TransportRefusalLedger.isStillAtOrigin(new WorldPoint(3201, 3169, 1), door, inside)); + assertFalse(TransportRefusalLedger.isStillAtOrigin(null, door, inside)); + } + + @Test + public void fallThroughsStrikeOutOnTheirOwnHigherLimit() + { + TransportRefusalLedger ledger = new TransportRefusalLedger(); + for (int i = 1; i < TransportRefusalLedger.FALL_THROUGH_STRIKE_LIMIT; i++) + { + assertFalse(TransportRefusalLedger.isFallThroughStrikeOut(ledger.registerFallThrough(ORIGIN, DESTINATION))); + } + assertTrue(TransportRefusalLedger.isFallThroughStrikeOut(ledger.registerFallThrough(ORIGIN, DESTINATION))); + assertEquals("fall-throughs do not count as interaction strikes", 0, ledger.strikes(ORIGIN, DESTINATION)); + ledger.clear(ORIGIN, DESTINATION); + assertEquals(0, ledger.fallThroughs(ORIGIN, DESTINATION)); + } + + @Test + public void dispatchClassificationSeparatesDeliberateSkipsFromRefusals() + { + assertEquals(Verdict.HANDLED, + TransportDispatchTrace.classify(true, true, null, Refusal.HANDLER_FAILED, true)); + assertEquals(Verdict.REFUSED_AFTER_INTERACTION, + TransportDispatchTrace.classify(false, true, null, Refusal.HANDLER_FAILED, true)); + assertEquals(Verdict.INTERACTION_LEFT_ORIGIN, + TransportDispatchTrace.classify(false, true, null, null, false)); + for (DeliberateSkip skip : DeliberateSkip.values()) + { + assertEquals(skip.name(), Verdict.DELIBERATE_SKIP, + TransportDispatchTrace.classify(false, false, skip, Refusal.ORIGIN_UNREACHABLE, true)); + } + for (Refusal refusal : Refusal.values()) + { + assertEquals(refusal.name(), Verdict.REFUSED_WITHOUT_INTERACTION, + TransportDispatchTrace.classify(false, false, null, refusal, true)); + } + assertEquals(Verdict.NOT_ATTEMPTED, + TransportDispatchTrace.classify(false, false, null, null, true)); + } + + @Test + public void successClearsStrikesAndWalkStartDrainsBlocks() + { + TransportRefusalLedger ledger = new TransportRefusalLedger(); + ledger.registerRefusal(ORIGIN, DESTINATION); + ledger.clear(ORIGIN, DESTINATION); + assertFalse(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + + ledger.recordWalkScopedBlock(ORIGIN, DESTINATION); + List drained = ledger.drainWalkScopedBlocks(); + assertEquals(1, drained.size()); + assertEquals(ORIGIN, drained.get(0)[0]); + assertEquals(DESTINATION, drained.get(0)[1]); + assertEquals("walk start resets strikes", 0, ledger.strikes(ORIGIN, DESTINATION)); + assertTrue(ledger.drainWalkScopedBlocks().isEmpty()); + } + + @Test + public void transportRefusedTwiceIsAvoidedByTheReplan() throws Exception + { + Transport refused = new Transport( + ORIGIN, DESTINATION, "refused", TransportType.TRANSPORT, false, + "Climb-down", "Trapdoor", 1001, 1); + Transport fallback = new Transport( + FALLBACK_ORIGIN, DESTINATION, "fallback", TransportType.TRANSPORT, false, + "Climb-down", "Ladder", 1002, 5); + Map> catalog = new HashMap<>(); + catalog.put(ORIGIN, Set.of(refused)); + catalog.put(FALLBACK_ORIGIN, Set.of(fallback)); + PathfinderConfig config = configWithTransports(catalog); + + List before = plan(config); + assertEquals(List.of(ORIGIN, DESTINATION), before); + + TransportRefusalLedger ledger = new TransportRefusalLedger(); + assertFalse(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertTrue(TransportRefusalLedger.isStrikeOut(ledger.registerRefusal(ORIGIN, DESTINATION))); + assertTrue(config.learnBlockedEdge(ORIGIN, DESTINATION, "transport-refused")); + + List after = plan(config); + assertEquals(DESTINATION, after.get(after.size() - 1)); + assertTrue("replan must board the fallback transport", after.contains(FALLBACK_ORIGIN)); + for (int i = 0; i + 1 < after.size(); i++) + { + assertFalse("replan must not reuse the refused edge", + after.get(i).equals(ORIGIN) && after.get(i + 1).equals(DESTINATION)); + } + + assertTrue(config.unlearnBlockedEdge(ORIGIN, DESTINATION, "walk ended")); + assertEquals(List.of(ORIGIN, DESTINATION), plan(config)); + } + + @Test + public void staticBlockedEdgeDoesNotHideItsOwnTransport() throws Exception + { + PathfinderConfig config = configWithTransports(Collections.emptyMap()); + int gateOrigin = WorldPointUtil.packWorldPoint(3267, 3227, 0); + int gateDestination = WorldPointUtil.packWorldPoint(3268, 3227, 0); + assertTrue(config.isBlockedTransportEdge(gateOrigin, gateDestination)); + assertFalse(config.isLearnedBlockedTransport(gateOrigin, gateDestination)); + } + + private static List plan(PathfinderConfig config) + { + return plan(config, ORIGIN, DESTINATION); + } + + static List plan(PathfinderConfig config, WorldPoint start, WorldPoint goal) + { + Pathfinder pathfinder = new Pathfinder(config, start, Set.of(goal)); + pathfinder.run(); + return pathfinder.getPath(); + } + + @SuppressWarnings("unchecked") + static PathfinderConfig configWithTransports(Map> catalog) throws Exception + { + PathfinderConfig config = new PathfinderConfig( + sharedCollisionMap(), new HashMap<>(catalog), Collections.emptyList(), null, null); + Field cutoff = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + cutoff.setAccessible(true); + cutoff.setLong(config, 10_000L); + + Field transportsField = PathfinderConfig.class.getDeclaredField("transports"); + transportsField.setAccessible(true); + ((Map>) transportsField.get(config)).putAll(catalog); + + Field packedField = PathfinderConfig.class.getDeclaredField("transportsPacked"); + packedField.setAccessible(true); + PrimitiveIntHashMap> packed = + (PrimitiveIntHashMap>) packedField.get(config); + for (Map.Entry> entry : catalog.entrySet()) + { + packed.put(WorldPointUtil.packWorldPoint(entry.getKey()), entry.getValue()); + } + return config; + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java new file mode 100644 index 00000000000..99604bd9d2d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/widget/Rs2WidgetNullRootTest.java @@ -0,0 +1,66 @@ +package net.runelite.client.plugins.microbot.util.widget; + +import net.runelite.api.Client; +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 org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Callable; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class Rs2WidgetNullRootTest { + + private MockedStatic microbot; + private Client client; + + @Before + public void setUp() throws Exception { + client = mock(Client.class); + ClientThread clientThread = mock(ClientThread.class); + when(clientThread.runOnClientThreadOptional(any())).thenAnswer(inv -> + Optional.ofNullable(((Callable) inv.getArgument(0)).call())); + microbot = Mockito.mockStatic(Microbot.class); + microbot.when(Microbot::getClient).thenReturn(client); + microbot.when(Microbot::getClientThread).thenReturn(clientThread); + } + + @After + public void tearDown() { + microbot.close(); + } + + @Test + public void clickWidgetReturnsFalseWhenRootWidgetNotLoaded() { + when(client.getWidget(270, 13)).thenReturn(null); + assertFalse(Rs2Widget.clickWidget("Make", Optional.of(270), 13, false)); + microbot.verify(Microbot::getMouse, Mockito.never()); + } + + @Test + public void findWidgetSkipsNullEntriesInChildren() { + Widget match = mock(Widget.class); + when(match.getText()).thenReturn("Make"); + when(match.getName()).thenReturn(""); + assertSame(match, Rs2Widget.findWidget("Make", Arrays.asList(null, match), true)); + assertNull(Rs2Widget.findWidget("Make", Arrays.asList((Widget) null), true)); + } + + @Test + public void searchChildrenReturnsNullForNullWidget() { + assertNull(Rs2Widget.searchChildren("Make", null, false)); + assertNull(Rs2Widget.searchChildren(1234, null)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java index 355aa3fa8f7..257a6567e4f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/skillcalculator/CalculatorTypeTest.java @@ -71,6 +71,21 @@ else if (currentXP == prevXP && currentName.compareTo(prevName) < 0) } } + @Test + public void skillActionsGrantXp() + { + for (final CalculatorType calculatorType : CalculatorType.values()) + { + for (final SkillAction skillAction : calculatorType.getSkillActions()) + { + if (skillAction.getXp() <= 0) + { + fail(calculatorType.getSkill().getName() + " skill action " + skillAction + " grants no xp."); + } + } + } + } + @Test public void testSkillBonusesMutuallyStack() { 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 91030114121..fac8ab0fc88 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 @@ -145,20 +145,15 @@ net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox#openDepositBo net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#clickOption(String, boolean): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getCombinationOptions(): List -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getCombinationOptions(): List -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getDialogueOptions(): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getDialogueOptions(): List -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getQuestion(): String -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getQuestion(): String -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#getStrippedWidgetText(int, int): String -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#handleQuestOptionDialogueSelection(): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#hasDialogueOptionTitle(String, boolean): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#hasDialogueOptionTitle(String, boolean): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#hasSelectAnOption(): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$3(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$4(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getCombinationOption$9(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getName(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getDialogueOption$1(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$hasDialogueOption$2(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$5(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$clickOption$6(Widget, String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getCombinationOption$11(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getName(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$getDialogueOption$3(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue#lambda$hasDialogueOption$4(boolean, String, Widget): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#(): void -> net.runelite.api.EquipmentInventorySlot#getSlotIdx(): int net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#(): void -> net.runelite.api.EquipmentInventorySlot#values(): EquipmentInventorySlot[] net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment#equipment(): ItemContainer -> net.runelite.api.Client#getItemContainer(int): ItemContainer @@ -580,12 +575,13 @@ net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean - net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.player.Rs2Player#isInteracting(): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.player.Rs2Player#isInteracting(): boolean -> net.runelite.api.Player#isInteracting(): boolean -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$30(Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$30(Rs2PlayerModel): boolean -> net.runelite.api.Player#getId(): int -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getPlayers$19(boolean, Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$32(Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getLocalPlayer$32(Rs2PlayerModel): boolean -> net.runelite.api.Player#getId(): int +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$getPlayers$21(boolean, Rs2PlayerModel): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$isInMemberWorld$8(World): boolean -> net.runelite.api.Client#getWorld(): int -net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$isStandingOnGameObject$45(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$isStandingOnGameObject$47(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.player.Rs2Player#lambda$waitForXpDrop$2(int, Skill, boolean): boolean -> net.runelite.api.Client#getSkillExperience(Skill): int +net.runelite.client.plugins.microbot.util.player.Rs2Player#logoutWithoutAutoLogin(): Rs2Player$TerminalLogoutState -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#waitForXpDrop(Skill, int, boolean): boolean -> net.runelite.api.Client#getSkillExperience(Skill): int net.runelite.client.plugins.microbot.util.player.Rs2Player$1#(): void -> net.runelite.api.Skill#ordinal(): int net.runelite.client.plugins.microbot.util.player.Rs2Player$1#(): void -> net.runelite.api.Skill#values(): Skill[] @@ -751,7 +747,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTransportsForPath(List, int, TransportType, boolean): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#interactingActorNearWalkablePath(): boolean -> net.runelite.api.Actor#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isClientThread(): boolean -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int diff --git a/scripts/run-release-e2e.sh b/scripts/run-release-e2e.sh new file mode 100755 index 00000000000..df03bcc0603 --- /dev/null +++ b/scripts/run-release-e2e.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/run-release-e2e.sh [quick|full] [CHECK_ID...] + +Runs the live release smoke harness (docs/RELEASE_E2E.md) against a logged-in F2P account. + +Options: + --jar PATH Run an already-built shaded jar (for example the release candidate) instead of building + --fail-fast Stop after the first failing check (logout still runs) + --list Print the check ids and exit + -h, --help Show this help + +Environment: + MICROBOT_E2E_JAR Same as --jar + MICROBOT_E2E_HOME user.home for the client (isolated runtime home with the saved profile) + MICROBOT_E2E_OUTPUT_DIR Result directory (default: /.runelite/test-results/release-e2e) + MICROBOT_E2E_EXPECTED_VERSION Version the client must report (default: microbot.version in gradle.properties, empty disables) + MICROBOT_E2E_TOTAL_TIMEOUT_MS Total budget for the checks (default: 900000 quick, 2100000 full) + MICROBOT_E2E_WORLD Log in to this world (use an F2P world for an F2P account) + MICROBOT_E2E_HOP_WORLD World for the world-hop check (default: least crowded other F2P world) + MICROBOT_E2E_HUB_PLUGIN Plugin Hub plugin for the hub check (default: AttackRangesPlusPlugin) + MICROBOT_E2E_GRADLE Gradle command used to build (default: ./gradlew) + MICROBOT_E2E_JAVA Java executable (default: java) + MICROBOT_E2E_JVM_ARGS Extra JVM arguments +EOF +} + +CHECK_IDS=(boot login state-caches widgets walker-transport bank npc-dialogue object-interaction agent-server + walker-medium grand-exchange world-hop hub-plugins script-lifecycle logout) + +TIER=quick +FAIL_FAST=false +JAR="${MICROBOT_E2E_JAR:-}" +CHECKS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + quick|full) TIER="$1" ;; + --jar) JAR="${2:?--jar needs a path}"; shift ;; + --fail-fast) FAIL_FAST=true ;; + --list) printf '%s\n' "${CHECK_IDS[@]}"; exit 0 ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option $1" >&2; usage >&2; exit 64 ;; + *) CHECKS+=("$1") ;; + esac + shift +done + +for id in ${CHECKS[@]+"${CHECKS[@]}"}; do + if [[ ! " ${CHECK_IDS[*]} " =~ " ${id} " ]]; then + echo "Unknown check id '$id'. Known ids: ${CHECK_IDS[*]}" >&2 + exit 64 + fi +done + +cd "$(dirname "$0")/.." + +RUNTIME_HOME="${MICROBOT_E2E_HOME:-$HOME}" +OUTPUT_DIR="${MICROBOT_E2E_OUTPUT_DIR:-$RUNTIME_HOME/.runelite/test-results/release-e2e}" +GRADLE_CMD="${MICROBOT_E2E_GRADLE:-./gradlew}" +JAVA_BIN="${MICROBOT_E2E_JAVA:-java}" + +if [[ -z "${DISPLAY:-}" && "$(uname)" == "Linux" ]]; then + echo "DISPLAY is not set; the client needs a display" >&2 + exit 65 +fi + +if [[ -z "$JAR" ]]; then + echo "Building shaded client jar..." + $GRADLE_CMD :client:shadowJar --console=plain + JAR="$(ls -t runelite-client/build/libs/client-*-shaded.jar 2>/dev/null | head -n 1 || true)" +fi +if [[ -z "$JAR" || ! -f "$JAR" ]]; then + echo "Shaded jar not found: '${JAR}'" >&2 + exit 66 +fi + +if [[ -n "${MICROBOT_E2E_EXPECTED_VERSION+x}" ]]; then + EXPECTED_VERSION="$MICROBOT_E2E_EXPECTED_VERSION" +else + EXPECTED_VERSION="$(sed -n 's/^microbot\.version=//p' gradle.properties | head -n 1)" +fi + +if [[ -n "${MICROBOT_E2E_TOTAL_TIMEOUT_MS:-}" ]]; then + TOTAL_TIMEOUT_MS="$MICROBOT_E2E_TOTAL_TIMEOUT_MS" +elif [[ "$TIER" == "full" ]]; then + TOTAL_TIMEOUT_MS=2100000 +else + TOTAL_TIMEOUT_MS=900000 +fi +RUNNER_TIMEOUT_MS=$((TOTAL_TIMEOUT_MS + 600000)) + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +IFS=',' CHECK_LIST="${CHECKS[*]:-}" +unset IFS + +CMD=("$JAVA_BIN" -ea -Dfile.encoding=UTF-8) +if [[ -n "${MICROBOT_E2E_HOME:-}" ]]; then + CMD+=("-Duser.home=$RUNTIME_HOME") +fi +if [[ -n "${MICROBOT_E2E_JVM_ARGS:-}" ]]; then + read -r -a EXTRA_JVM_ARGS <<< "$MICROBOT_E2E_JVM_ARGS" + CMD+=("${EXTRA_JVM_ARGS[@]}") +fi +CMD+=( + -Dmicrobot.disableTelemetry=true + -Dmicrobot.test.mode=true + "-Dmicrobot.test.script=Release Smoke Harness" + "-Dmicrobot.test.timeout=$RUNNER_TIMEOUT_MS" + "-Dmicrobot.test.output=$OUTPUT_DIR" + "-Dmicrobot.test.release.tier=$TIER" + "-Dmicrobot.test.release.failFast=$FAIL_FAST" + "-Dmicrobot.test.release.totalTimeoutMs=$TOTAL_TIMEOUT_MS" + "-Dmicrobot.test.release.expectedVersion=$EXPECTED_VERSION" +) +[[ -n "$CHECK_LIST" ]] && CMD+=("-Dmicrobot.test.release.checks=$CHECK_LIST") +[[ -n "${MICROBOT_E2E_WORLD:-}" ]] && CMD+=("-Dmicrobot.test.world=$MICROBOT_E2E_WORLD") +[[ -n "${MICROBOT_E2E_HOP_WORLD:-}" ]] && CMD+=("-Dmicrobot.test.release.hopWorld=$MICROBOT_E2E_HOP_WORLD") +[[ -n "${MICROBOT_E2E_HUB_PLUGIN:-}" ]] && CMD+=("-Dmicrobot.test.release.hubPlugin=$MICROBOT_E2E_HUB_PLUGIN") +CMD+=(-jar "$JAR" --disable-telemetry) + +echo "Running release E2E ($TIER${CHECK_LIST:+: $CHECK_LIST}) with $JAR" +START=$(date +%s) +set +e +"${CMD[@]}" > /dev/null 2> "$OUTPUT_DIR/client-stderr.log" +STATUS=$? +set -e +ELAPSED=$(( $(date +%s) - START )) + +if [[ -f "$OUTPUT_DIR/summary.md" ]]; then + cat "$OUTPUT_DIR/summary.md" +else + echo "No summary written; see $OUTPUT_DIR/client-stderr.log and ~/.runelite/logs/client.log" >&2 + tail -n 40 "$OUTPUT_DIR/client-stderr.log" >&2 || true +fi +if [[ ! -f "$OUTPUT_DIR/result.json" ]]; then + echo "No result.json written in $OUTPUT_DIR" >&2 + [[ "$STATUS" -eq 0 ]] && STATUS=1 +fi + +echo +echo "Wall clock: ${ELAPSED}s, client exit code: $STATUS" +echo "Results: $OUTPUT_DIR/result.json, $OUTPUT_DIR/summary.md" +exit "$STATUS"