From a61c5abbdce5f0727b73b34a63c4915e9c296620 Mon Sep 17 00:00:00 2001 From: itsbotzilla <25913563+itsBOTzilla@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:54:17 -0500 Subject: [PATCH 1/5] Fix Misthalin mirror showdown automation --- .../questhelper/QuestHelperPlugin.java | 2 +- .../logic/MisthalinMirrorPlanner.java | 254 ++++++++++++++++++ .../questhelper/logic/MisthalinMystery.java | 226 +++++++++++++++- .../logic/MisthalinMirrorPlannerTest.java | 129 +++++++++ 4 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index cebe26e283..b088262f8c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -79,7 +79,7 @@ @PluginDescriptor( name = "Quest Helper", - version = "1.0.8", + version = "1.0.9", description = "Helps you with questing", tags = { "quest", "helper", "overlay" } ) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java new file mode 100644 index 0000000000..7a6523a099 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java @@ -0,0 +1,254 @@ +package net.runelite.client.plugins.microbot.questhelper.logic; + +import java.util.Objects; +import java.util.function.Predicate; + +/** Pure scene-coordinate planning and dispatch state for the Misthalin mirror showdown. */ +final class MisthalinMirrorPlanner +{ + private MisthalinMirrorPlanner() + { + } + + static PushPlan nextPush(SceneTile mirror, SceneTile wardrobe, + Predicate canOccupy) + { + if (mirror == null || wardrobe == null || canOccupy == null || mirror.equals(wardrobe)) + { + return null; + } + + int deltaX = wardrobe.getX() - mirror.getX(); + int deltaY = wardrobe.getY() - mirror.getY(); + if (deltaX == 0) + { + return validPlan(mirror, Direction.vertical(deltaY), true, canOccupy); + } + if (deltaY == 0) + { + return validPlan(mirror, Direction.horizontal(deltaX), true, canOccupy); + } + + Direction first; + Direction second; + if (Math.abs(deltaY) < Math.abs(deltaX)) + { + first = Direction.vertical(deltaY); + second = Direction.horizontal(deltaX); + } + else + { + first = Direction.horizontal(deltaX); + second = Direction.vertical(deltaY); + } + + PushPlan plan = validPlan(mirror, first, false, canOccupy); + return plan != null ? plan : validPlan(mirror, second, false, canOccupy); + } + + private static PushPlan validPlan(SceneTile mirror, Direction direction, boolean finalAim, + Predicate canOccupy) + { + SceneTile stand = mirror.translate(-direction.getDeltaX(), -direction.getDeltaY()); + SceneTile expected = mirror.translate(direction.getDeltaX(), direction.getDeltaY()); + return canOccupy.test(stand) && canOccupy.test(expected) + ? new PushPlan(direction, stand, expected, finalAim) + : null; + } + + enum Direction + { + NORTH(0, 1), + EAST(1, 0), + SOUTH(0, -1), + WEST(-1, 0); + + private final int deltaX; + private final int deltaY; + + Direction(int deltaX, int deltaY) + { + this.deltaX = deltaX; + this.deltaY = deltaY; + } + + int getDeltaX() + { + return deltaX; + } + + int getDeltaY() + { + return deltaY; + } + + static Direction horizontal(int delta) + { + return delta > 0 ? EAST : WEST; + } + + static Direction vertical(int delta) + { + return delta > 0 ? NORTH : SOUTH; + } + } + + static final class SceneTile + { + private final int x; + private final int y; + + SceneTile(int x, int y) + { + this.x = x; + this.y = y; + } + + int getX() + { + return x; + } + + int getY() + { + return y; + } + + SceneTile translate(int deltaX, int deltaY) + { + return new SceneTile(x + deltaX, y + deltaY); + } + + @Override + public boolean equals(Object other) + { + if (this == other) + { + return true; + } + if (!(other instanceof SceneTile)) + { + return false; + } + SceneTile tile = (SceneTile) other; + return x == tile.x && y == tile.y; + } + + @Override + public int hashCode() + { + return Objects.hash(x, y); + } + } + + static final class PushPlan + { + private final Direction direction; + private final SceneTile standTile; + private final SceneTile expectedMirrorTile; + private final boolean finalAim; + + PushPlan(Direction direction, SceneTile standTile, SceneTile expectedMirrorTile, + boolean finalAim) + { + this.direction = direction; + this.standTile = standTile; + this.expectedMirrorTile = expectedMirrorTile; + this.finalAim = finalAim; + } + + Direction getDirection() + { + return direction; + } + + SceneTile getStandTile() + { + return standTile; + } + + SceneTile getExpectedMirrorTile() + { + return expectedMirrorTile; + } + + boolean isFinalAim() + { + return finalAim; + } + } + + static final class AttackState + { + private SceneTile activeWardrobe; + private SceneTile pendingFrom; + private SceneTile pendingExpected; + private boolean pendingFinalAim; + private long pendingDeadline; + private boolean aimedForCurrentAttack; + + void observe(SceneTile mirror, SceneTile wardrobe, long now) + { + if (wardrobe == null) + { + reset(); + return; + } + if (!wardrobe.equals(activeWardrobe)) + { + activeWardrobe = wardrobe; + clearPending(); + aimedForCurrentAttack = false; + } + if (pendingExpected == null || mirror == null) + { + return; + } + if (mirror.equals(pendingExpected)) + { + aimedForCurrentAttack = pendingFinalAim; + clearPending(); + } + else if (!mirror.equals(pendingFrom) || now - pendingDeadline >= 0) + { + clearPending(); + } + } + + boolean canDispatch(long now) + { + if (aimedForCurrentAttack) + { + return false; + } + if (pendingExpected != null && now - pendingDeadline >= 0) + { + clearPending(); + } + return pendingExpected == null; + } + + void recordDispatch(SceneTile mirror, PushPlan plan, long now, long timeout) + { + pendingFrom = mirror; + pendingExpected = plan.getExpectedMirrorTile(); + pendingFinalAim = plan.isFinalAim(); + pendingDeadline = now + timeout; + } + + void reset() + { + activeWardrobe = null; + aimedForCurrentAttack = false; + clearPending(); + } + + private void clearPending() + { + pendingFrom = null; + pendingExpected = null; + pendingFinalAim = false; + pendingDeadline = 0; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java index efaeee65f9..33ee5b4239 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java @@ -2,18 +2,27 @@ import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.function.BooleanSupplier; +import java.util.function.Function; import net.runelite.api.NullObjectID; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID; import net.runelite.client.plugins.microbot.Microbot; +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.questhelper.QuestHelperPlugin; import net.runelite.client.plugins.microbot.questhelper.steps.DetailedQuestStep; import net.runelite.client.plugins.microbot.questhelper.steps.ObjectStep; import net.runelite.client.plugins.microbot.questhelper.steps.QuestStep; import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; -import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.input.InputArbiter; +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; /** Quest-specific route sequencing validated for the Misthalin Mystery instance. */ @@ -21,6 +30,9 @@ public class MisthalinMystery extends BaseQuest { private static final long DAMAGED_WALL_CANVAS_RETRY_NANOS = 1_500_000_000L; private static final long DAMAGED_WALL_INTERACT_RETRY_NANOS = 1_500_000_000L; + private static final long MIRROR_MOVE_RETRY_NANOS = 1_200_000_000L; + private static final long MIRROR_PUSH_RETRY_NANOS = 1_800_000_000L; + private static final String MIRROR_SHOWDOWN_MARKER = "move the mirror to reflect the knives"; private static final String LACEY_INTERRUPT_QUESTION = "Interrupt with answer?"; private static final String LACEY_INTERRUPT_ANSWER = "Count Check"; private static final WorldPoint PAINTING_OBJECT = new WorldPoint(1632, 4833, 0); @@ -43,9 +55,15 @@ public class MisthalinMystery extends BaseQuest new WorldPoint(1646, 4836, 0)); private final QuestApproachSequence approachSequence = new QuestApproachSequence(); + private final MisthalinMirrorPlanner.AttackState mirrorAttackState = + new MisthalinMirrorPlanner.AttackState(); private volatile long nextDamagedWallLocalAt; private volatile long nextDamagedWallCanvasAt; private volatile long nextDamagedWallInteractAt; + private volatile long nextMirrorMoveAt; + private volatile long nextMirrorPushAt; + private MisthalinMirrorPlanner.SceneTile mirrorMoveTarget; + private boolean mirrorShowdownActive; @Override public boolean executeCustomLogic() @@ -56,6 +74,7 @@ public boolean executeCustomLogic() { approachSequence.reset(); resetDamagedWallApproach(); + resetMirrorShowdown(); return true; } @@ -70,6 +89,12 @@ public boolean executeCustomLogic() { return false; } + if (step instanceof DetailedQuestStep + && isMirrorShowdownText(((DetailedQuestStep) step).getText())) + { + return handleMirrorShowdown(); + } + resetMirrorShowdown(); if (!(step instanceof ObjectStep)) { approachSequence.reset(); @@ -137,6 +162,205 @@ public void reset() { approachSequence.reset(); resetDamagedWallApproach(); + resetMirrorShowdown(); + } + + static boolean isMirrorShowdownText(List text) + { + return text != null && text.stream() + .filter(line -> line != null) + .map(line -> line.toLowerCase(Locale.ENGLISH)) + .anyMatch(line -> line.contains(MIRROR_SHOWDOWN_MARKER)); + } + + private boolean handleMirrorShowdown() + { + if (!mirrorShowdownActive) + { + Rs2Walker.clearWalkingRoute("quest-helper:misthalin-mirror-showdown"); + mirrorShowdownActive = true; + } + + MirrorSnapshot snapshot = captureMirrorSnapshot(); + long now = System.nanoTime(); + if (snapshot == null || snapshot.mirrorTile == null) + { + mirrorAttackState.reset(); + return false; + } + + mirrorAttackState.observe(snapshot.mirrorTile, snapshot.wardrobeTile, now); + if (snapshot.wardrobeTile == null || !mirrorAttackState.canDispatch(now)) + { + return false; + } + + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + snapshot.mirrorTile, snapshot.wardrobeTile, + tile -> isWalkableSceneTile(tile, snapshot.worldViewId)); + if (plan == null) + { + return false; + } + + if (!plan.getStandTile().equals(snapshot.playerTile)) + { + nextMirrorPushAt = 0; + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) + { + return false; + } + if (plan.getStandTile().equals(mirrorMoveTarget) + && now - nextMirrorMoveAt < 0) + { + return false; + } + + WorldPoint standPoint = toInstanceWorldPoint( + plan.getStandTile(), snapshot.worldViewId); + if (standPoint == null) + { + return false; + } + mirrorMoveTarget = plan.getStandTile(); + nextMirrorMoveAt = now + MIRROR_MOVE_RETRY_NANOS; + Rs2Walker.clearWalkingRoute("quest-helper:misthalin-mirror-position"); + if (!dispatchMirrorMove(standPoint, Rs2Walker::walkFastCanvas)) + { + mirrorMoveTarget = null; + nextMirrorMoveAt = 0; + } + return false; + } + + mirrorMoveTarget = null; + nextMirrorMoveAt = 0; + if (Rs2Player.isMoving() || Rs2Player.isAnimating() + || now - nextMirrorPushAt < 0) + { + return false; + } + + nextMirrorPushAt = now + MIRROR_PUSH_RETRY_NANOS; + Rs2Walker.clearWalkingRoute("quest-helper:misthalin-mirror-push"); + if (snapshot.mirror.click("Push")) + { + mirrorAttackState.recordDispatch( + snapshot.mirrorTile, plan, now, MIRROR_PUSH_RETRY_NANOS); + } + return false; + } + + private MirrorSnapshot captureMirrorSnapshot() + { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Player player = Microbot.getClient().getLocalPlayer(); + if (player == null || player.getLocalLocation() == null + || player.getWorldView() == null) + { + return null; + } + Rs2NpcModel mirror = Microbot.getRs2NpcCache().query() + .fromWorldView() + .withId(NpcID.MISTMYST_MIRROR_MOVABLE) + .first(); + if (mirror == null || mirror.getLocalLocation() == null) + { + return null; + } + Rs2TileObjectModel wardrobe = Microbot.getRs2TileObjectCache().query() + .fromWorldView() + .withId(ObjectID.MISTMYST_BOSS_WARDROBE_OPEN) + .first(); + return new MirrorSnapshot( + mirror, + sceneTile(player.getLocalLocation()), + sceneTile(mirror.getLocalLocation()), + wardrobe == null ? null : sceneTile(wardrobe.getLocalLocation()), + player.getWorldView().getId()); + }).orElse(null); + } + + private boolean isWalkableSceneTile(MisthalinMirrorPlanner.SceneTile tile, int worldViewId) + { + LocalPoint localPoint = toLocalPoint(tile, worldViewId); + return localPoint != null && localPoint.isInScene() && Rs2Tile.isWalkable(localPoint); + } + + private LocalPoint toLocalPoint(MisthalinMirrorPlanner.SceneTile tile, int worldViewId) + { + if (tile == null) + { + return null; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + if (Microbot.getClient().getWorldView(worldViewId) == null) + { + return null; + } + return LocalPoint.fromScene( + tile.getX(), tile.getY(), Microbot.getClient().getWorldView(worldViewId)); + }).orElse(null); + } + + private WorldPoint toInstanceWorldPoint(MisthalinMirrorPlanner.SceneTile tile, int worldViewId) + { + LocalPoint localPoint = toLocalPoint(tile, worldViewId); + if (localPoint == null || !localPoint.isInScene()) + { + return null; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + if (Microbot.getClient().getWorldView(worldViewId) == null) + { + return null; + } + return WorldPoint.fromLocalInstance( + Microbot.getClient(), localPoint, + Microbot.getClient().getWorldView(worldViewId).getPlane()); + }).orElse(null); + } + + static boolean dispatchMirrorMove(WorldPoint target, Function canvasMove) + { + return target != null && canvasMove != null && Boolean.TRUE.equals(canvasMove.apply(target)); + } + + private static MisthalinMirrorPlanner.SceneTile sceneTile(LocalPoint point) + { + return point == null ? null + : new MisthalinMirrorPlanner.SceneTile(point.getSceneX(), point.getSceneY()); + } + + private void resetMirrorShowdown() + { + mirrorAttackState.reset(); + nextMirrorMoveAt = 0; + nextMirrorPushAt = 0; + mirrorMoveTarget = null; + mirrorShowdownActive = false; + } + + private static final class MirrorSnapshot + { + private final Rs2NpcModel mirror; + private final MisthalinMirrorPlanner.SceneTile playerTile; + private final MisthalinMirrorPlanner.SceneTile mirrorTile; + private final MisthalinMirrorPlanner.SceneTile wardrobeTile; + private final int worldViewId; + + private MirrorSnapshot(Rs2NpcModel mirror, + MisthalinMirrorPlanner.SceneTile playerTile, + MisthalinMirrorPlanner.SceneTile mirrorTile, + MisthalinMirrorPlanner.SceneTile wardrobeTile, + int worldViewId) + { + this.mirror = mirror; + this.playerTile = playerTile; + this.mirrorTile = mirrorTile; + this.wardrobeTile = wardrobeTile; + this.worldViewId = worldViewId; + } } static List approachRoute(WorldPoint objectLocation, List stepText) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java new file mode 100644 index 0000000000..da854e6bcc --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java @@ -0,0 +1,129 @@ +package net.runelite.client.plugins.microbot.questhelper.logic; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +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 MisthalinMirrorPlannerTest +{ + @Test + public void alignedMirrorIsPushedTowardTheAttackingWardrobe() + { + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + tile(10, 10), tile(10, 14), ignored -> true); + + assertEquals(MisthalinMirrorPlanner.Direction.NORTH, plan.getDirection()); + assertEquals(tile(10, 9), plan.getStandTile()); + assertEquals(tile(10, 11), plan.getExpectedMirrorTile()); + assertTrue(plan.isFinalAim()); + } + + @Test + public void unalignedMirrorUsesTheShorterAxisToReachAnAttackLane() + { + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + tile(10, 10), tile(14, 12), ignored -> true); + + assertEquals(MisthalinMirrorPlanner.Direction.NORTH, plan.getDirection()); + assertEquals(tile(10, 9), plan.getStandTile()); + assertEquals(tile(10, 11), plan.getExpectedMirrorTile()); + assertFalse(plan.isFinalAim()); + } + + @Test + public void blockedPreferredAxisFallsBackToTheOtherAlignmentLane() + { + Set blocked = new HashSet<>(); + blocked.add(tile(10, 9)); + + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + tile(10, 10), tile(14, 12), point -> !blocked.contains(point)); + + assertEquals(MisthalinMirrorPlanner.Direction.EAST, plan.getDirection()); + assertEquals(tile(9, 10), plan.getStandTile()); + assertEquals(tile(11, 10), plan.getExpectedMirrorTile()); + } + + @Test + public void plannerRejectsPushesWithoutAValidStandAndDestination() + { + assertNull(MisthalinMirrorPlanner.nextPush( + tile(10, 10), tile(14, 12), ignored -> false)); + } + + @Test + public void confirmedFinalPushSuppressesDuplicatesUntilTheAttackCycleEnds() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 14); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, ignored -> true); + + state.observe(mirror, wardrobe, 1_000L); + assertTrue(state.canDispatch(1_000L)); + state.recordDispatch(mirror, plan, 1_000L, 500L); + assertFalse(state.canDispatch(1_200L)); + + state.observe(plan.getExpectedMirrorTile(), wardrobe, 1_300L); + assertFalse("A confirmed final aim must not be repeated in the same attack cycle", + state.canDispatch(2_000L)); + + state.observe(plan.getExpectedMirrorTile(), null, 2_100L); + state.observe(plan.getExpectedMirrorTile(), wardrobe, 2_200L); + assertTrue("The same wardrobe tile may attack again after it visibly closes", + state.canDispatch(2_200L)); + } + + @Test + public void unobservedPushCanBeRetriedAfterItsDeadline() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 14); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, ignored -> true); + + state.observe(mirror, wardrobe, 1_000L); + state.recordDispatch(mirror, plan, 1_000L, 500L); + + assertFalse(state.canDispatch(1_499L)); + assertTrue(state.canDispatch(1_500L)); + } + + @Test + public void mirrorInstructionIsClaimedWithoutRequiringADefinedPoint() + { + assertTrue(MisthalinMystery.isMirrorShowdownText(java.util.List.of( + "This puzzle requires you to move the mirror to reflect the knives the murderer throws.", + "You can tell which wardrobe the murderer will throw from by a black swirl."))); + assertFalse(MisthalinMystery.isMirrorShowdownText(java.util.List.of( + "Climb over the damaged wall."))); + } + + @Test + public void mirrorStandApproachUsesTheCanvasWalkerWithItsOffscreenFallback() + { + WorldPoint target = new WorldPoint(1630, 4830, 0); + AtomicReference dispatched = new AtomicReference<>(); + + assertTrue(MisthalinMystery.dispatchMirrorMove(target, point -> { + dispatched.set(point); + return true; + })); + assertEquals(target, dispatched.get()); + assertFalse(MisthalinMystery.dispatchMirrorMove(null, ignored -> true)); + } + + private static MisthalinMirrorPlanner.SceneTile tile(int x, int y) + { + return new MisthalinMirrorPlanner.SceneTile(x, y); + } +} From d30f626dfe43269512cd787c94e985f608d5c7f9 Mon Sep 17 00:00:00 2001 From: itsbotzilla <25913563+itsBOTzilla@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:11:18 -0500 Subject: [PATCH 2/5] Detect Misthalin wardrobe graphic cue --- .../questhelper/QuestHelperPlugin.java | 8 +- .../microbot/questhelper/QuestScript.java | 14 +++- .../microbot/questhelper/logic/IQuest.java | 6 ++ .../logic/MisthalinMirrorPlanner.java | 75 ++++++++++++++++++- .../questhelper/logic/MisthalinMystery.java | 44 +++++++++-- .../logic/MisthalinMirrorPlannerTest.java | 38 ++++++++++ 6 files changed, 174 insertions(+), 11 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index b088262f8c..4ae3c4fdca 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -79,7 +79,7 @@ @PluginDescriptor( name = "Quest Helper", - version = "1.0.9", + version = "1.0.10", description = "Helps you with questing", tags = { "quest", "helper", "overlay" } ) @@ -265,6 +265,12 @@ public void onGameTick(GameTick event) questManager.updateQuestState(); } + @Subscribe + public void onGraphicsObjectCreated(GraphicsObjectCreated event) + { + questScript.onGraphicsObjectCreated(event.getGraphicsObject()); + } + @Subscribe public void onItemContainerChanged(ItemContainerChanged event) { 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 4e624029a2..2657e15dad 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 @@ -101,7 +101,7 @@ public class QuestScript extends Script { private long interactionSequence; private long targetReadyAt; private QuestStep lastCustomStep; - private long nextCustomAttemptAt; + private volatile long nextCustomAttemptAt; private boolean customActionPending; private static WorldPoint scenePlayerLocation() { @@ -1391,6 +1391,18 @@ private boolean executeQuestCustomLogic() { return questLogic == null || questLogic.executeCustomLogic(); } + public void onGraphicsObjectCreated(GraphicsObject graphicsObject) { + if (graphicsObject == null || getQuestHelperPlugin() == null + || getQuestHelperPlugin().getSelectedQuest() == null) { + return; + } + var questLogic = QuestRegistry.getQuest( + getQuestHelperPlugin().getSelectedQuest().getQuest().getId()); + if (questLogic != null && questLogic.onGraphicsObjectCreated(graphicsObject)) { + nextCustomAttemptAt = 0; + } + } + private boolean runIdleCustomLogic(QuestStep step) { long now = System.nanoTime(); if (lastCustomStep == step && now - nextCustomAttemptAt < 0) return !customActionPending; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java index 1d03ba7a6f..3a2065b803 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java @@ -1,8 +1,14 @@ package net.runelite.client.plugins.microbot.questhelper.logic; +import net.runelite.api.GraphicsObject; + public interface IQuest { boolean executeCustomLogic(); + default boolean onGraphicsObjectCreated(GraphicsObject graphicsObject) { + return false; + } + default void reset() { } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java index 7a6523a099..de170c4040 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java @@ -181,6 +181,8 @@ boolean isFinalAim() static final class AttackState { private SceneTile activeWardrobe; + private long activeCycle = Long.MIN_VALUE; + private long inferredCycle; private SceneTile pendingFrom; private SceneTile pendingExpected; private boolean pendingFinalAim; @@ -188,15 +190,30 @@ static final class AttackState private boolean aimedForCurrentAttack; void observe(SceneTile mirror, SceneTile wardrobe, long now) + { + if (wardrobe == null) + { + observe(mirror, null, Long.MIN_VALUE, now); + return; + } + if (activeWardrobe == null || !wardrobe.equals(activeWardrobe)) + { + inferredCycle++; + } + observe(mirror, wardrobe, inferredCycle, now); + } + + void observe(SceneTile mirror, SceneTile wardrobe, long cycle, long now) { if (wardrobe == null) { reset(); return; } - if (!wardrobe.equals(activeWardrobe)) + if (cycle != activeCycle || !wardrobe.equals(activeWardrobe)) { activeWardrobe = wardrobe; + activeCycle = cycle; clearPending(); aimedForCurrentAttack = false; } @@ -239,6 +256,7 @@ void recordDispatch(SceneTile mirror, PushPlan plan, long now, long timeout) void reset() { activeWardrobe = null; + activeCycle = Long.MIN_VALUE; aimedForCurrentAttack = false; clearPending(); } @@ -251,4 +269,59 @@ private void clearPending() pendingDeadline = 0; } } + + static final class CueState + { + private long nextCycle; + private WardrobeCue cue; + + synchronized boolean record(int graphicId, SceneTile tile, int worldViewId) + { + if (graphicId != 483 || tile == null) + { + return false; + } + cue = new WardrobeCue(tile, worldViewId, ++nextCycle); + return true; + } + + synchronized WardrobeCue snapshot() + { + return cue; + } + + synchronized void reset() + { + cue = null; + } + } + + static final class WardrobeCue + { + private final SceneTile tile; + private final int worldViewId; + private final long cycle; + + private WardrobeCue(SceneTile tile, int worldViewId, long cycle) + { + this.tile = tile; + this.worldViewId = worldViewId; + this.cycle = cycle; + } + + SceneTile getTile() + { + return tile; + } + + int getWorldViewId() + { + return worldViewId; + } + + long getCycle() + { + return cycle; + } + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java index 33ee5b4239..d4d4f56baf 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java @@ -5,15 +5,15 @@ import java.util.Locale; import java.util.function.BooleanSupplier; import java.util.function.Function; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.GraphicsObject; import net.runelite.api.NullObjectID; import net.runelite.api.Player; import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.NpcID; -import net.runelite.api.gameval.ObjectID; import net.runelite.client.plugins.microbot.Microbot; 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.questhelper.QuestHelperPlugin; import net.runelite.client.plugins.microbot.questhelper.steps.DetailedQuestStep; import net.runelite.client.plugins.microbot.questhelper.steps.ObjectStep; @@ -26,6 +26,7 @@ import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; /** Quest-specific route sequencing validated for the Misthalin Mystery instance. */ +@Slf4j public class MisthalinMystery extends BaseQuest { private static final long DAMAGED_WALL_CANVAS_RETRY_NANOS = 1_500_000_000L; @@ -57,6 +58,8 @@ public class MisthalinMystery extends BaseQuest private final QuestApproachSequence approachSequence = new QuestApproachSequence(); private final MisthalinMirrorPlanner.AttackState mirrorAttackState = new MisthalinMirrorPlanner.AttackState(); + private final MisthalinMirrorPlanner.CueState wardrobeCueState = + new MisthalinMirrorPlanner.CueState(); private volatile long nextDamagedWallLocalAt; private volatile long nextDamagedWallCanvasAt; private volatile long nextDamagedWallInteractAt; @@ -165,6 +168,26 @@ public void reset() resetMirrorShowdown(); } + @Override + public boolean onGraphicsObjectCreated(GraphicsObject graphicsObject) + { + if (graphicsObject == null || graphicsObject.getWorldView() == null) + { + return false; + } + MisthalinMirrorPlanner.SceneTile cueTile = sceneTile(graphicsObject.getLocation()); + if (!wardrobeCueState.record( + graphicsObject.getId(), cueTile, graphicsObject.getWorldView().getId())) + { + return false; + } + MisthalinMirrorPlanner.WardrobeCue cue = wardrobeCueState.snapshot(); + log.info("[MisthalinMirror] wardrobe cue | graphic={} scene={},{} worldView={} cycle={}", + graphicsObject.getId(), cueTile.getX(), cueTile.getY(), + cue.getWorldViewId(), cue.getCycle()); + return true; + } + static boolean isMirrorShowdownText(List text) { return text != null && text.stream() @@ -189,7 +212,8 @@ private boolean handleMirrorShowdown() return false; } - mirrorAttackState.observe(snapshot.mirrorTile, snapshot.wardrobeTile, now); + mirrorAttackState.observe( + snapshot.mirrorTile, snapshot.wardrobeTile, snapshot.attackCycle, now); if (snapshot.wardrobeTile == null || !mirrorAttackState.canDispatch(now)) { return false; @@ -268,15 +292,15 @@ private MirrorSnapshot captureMirrorSnapshot() { return null; } - Rs2TileObjectModel wardrobe = Microbot.getRs2TileObjectCache().query() - .fromWorldView() - .withId(ObjectID.MISTMYST_BOSS_WARDROBE_OPEN) - .first(); + MisthalinMirrorPlanner.WardrobeCue cue = wardrobeCueState.snapshot(); + boolean cueInPlayerWorldView = cue != null + && cue.getWorldViewId() == player.getWorldView().getId(); return new MirrorSnapshot( mirror, sceneTile(player.getLocalLocation()), sceneTile(mirror.getLocalLocation()), - wardrobe == null ? null : sceneTile(wardrobe.getLocalLocation()), + cueInPlayerWorldView ? cue.getTile() : null, + cueInPlayerWorldView ? cue.getCycle() : Long.MIN_VALUE, player.getWorldView().getId()); }).orElse(null); } @@ -335,6 +359,7 @@ private static MisthalinMirrorPlanner.SceneTile sceneTile(LocalPoint point) private void resetMirrorShowdown() { mirrorAttackState.reset(); + wardrobeCueState.reset(); nextMirrorMoveAt = 0; nextMirrorPushAt = 0; mirrorMoveTarget = null; @@ -347,18 +372,21 @@ private static final class MirrorSnapshot private final MisthalinMirrorPlanner.SceneTile playerTile; private final MisthalinMirrorPlanner.SceneTile mirrorTile; private final MisthalinMirrorPlanner.SceneTile wardrobeTile; + private final long attackCycle; private final int worldViewId; private MirrorSnapshot(Rs2NpcModel mirror, MisthalinMirrorPlanner.SceneTile playerTile, MisthalinMirrorPlanner.SceneTile mirrorTile, MisthalinMirrorPlanner.SceneTile wardrobeTile, + long attackCycle, int worldViewId) { this.mirror = mirror; this.playerTile = playerTile; this.mirrorTile = mirrorTile; this.wardrobeTile = wardrobeTile; + this.attackCycle = attackCycle; this.worldViewId = worldViewId; } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java index da854e6bcc..43ddc0a525 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java @@ -98,6 +98,44 @@ public void unobservedPushCanBeRetriedAfterItsDeadline() assertTrue(state.canDispatch(1_500L)); } + @Test + public void repeatedGraphicAtSameWardrobeStartsANewAttackCycle() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 14); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, ignored -> true); + + state.observe(mirror, wardrobe, 1L, 1_000L); + state.recordDispatch(mirror, plan, 1_000L, 500L); + state.observe(plan.getExpectedMirrorTile(), wardrobe, 1L, 1_300L); + assertFalse(state.canDispatch(1_400L)); + + state.observe(plan.getExpectedMirrorTile(), wardrobe, 2L, 2_000L); + assertTrue("A new graphic spawn must start a new attack even at the same wardrobe", + state.canDispatch(2_000L)); + } + + @Test + public void cueStateOnlyAcceptsGraphic483AndNumbersEverySpawn() + { + MisthalinMirrorPlanner.CueState state = new MisthalinMirrorPlanner.CueState(); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(12, 15); + + assertFalse(state.record(482, wardrobe, 7)); + assertNull(state.snapshot()); + + assertTrue(state.record(483, wardrobe, 7)); + MisthalinMirrorPlanner.WardrobeCue first = state.snapshot(); + assertEquals(wardrobe, first.getTile()); + assertEquals(7, first.getWorldViewId()); + assertEquals(1L, first.getCycle()); + + assertTrue(state.record(483, wardrobe, 7)); + assertEquals(2L, state.snapshot().getCycle()); + } + @Test public void mirrorInstructionIsClaimedWithoutRequiringADefinedPoint() { From 0a82613e137c9c849cc2e524ce0a3963d4c87678 Mon Sep 17 00:00:00 2001 From: itsbotzilla <25913563+itsBOTzilla@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:27:56 -0500 Subject: [PATCH 3/5] Speed up Misthalin mirror positioning --- .../questhelper/QuestHelperPlugin.java | 2 +- .../logic/MisthalinMirrorPlanner.java | 30 +++++++++++++------ .../questhelper/logic/MisthalinMystery.java | 7 +++-- .../logic/MisthalinMirrorPlannerTest.java | 27 +++++++++++++++-- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index 4ae3c4fdca..39e34a4754 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -79,7 +79,7 @@ @PluginDescriptor( name = "Quest Helper", - version = "1.0.10", + version = "1.0.11", description = "Helps you with questing", tags = { "quest", "helper", "overlay" } ) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java index de170c4040..efa981981e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java @@ -188,27 +188,27 @@ static final class AttackState private boolean pendingFinalAim; private long pendingDeadline; private boolean aimedForCurrentAttack; + private boolean failedForCurrentAttack; - void observe(SceneTile mirror, SceneTile wardrobe, long now) + boolean observe(SceneTile mirror, SceneTile wardrobe, long now) { if (wardrobe == null) { - observe(mirror, null, Long.MIN_VALUE, now); - return; + return observe(mirror, null, Long.MIN_VALUE, now); } if (activeWardrobe == null || !wardrobe.equals(activeWardrobe)) { inferredCycle++; } - observe(mirror, wardrobe, inferredCycle, now); + return observe(mirror, wardrobe, inferredCycle, now); } - void observe(SceneTile mirror, SceneTile wardrobe, long cycle, long now) + boolean observe(SceneTile mirror, SceneTile wardrobe, long cycle, long now) { if (wardrobe == null) { reset(); - return; + return false; } if (cycle != activeCycle || !wardrobe.equals(activeWardrobe)) { @@ -216,31 +216,42 @@ void observe(SceneTile mirror, SceneTile wardrobe, long cycle, long now) activeCycle = cycle; clearPending(); aimedForCurrentAttack = false; + failedForCurrentAttack = false; } if (pendingExpected == null || mirror == null) { - return; + return false; } if (mirror.equals(pendingExpected)) { aimedForCurrentAttack = pendingFinalAim; clearPending(); + return true; + } + if (!mirror.equals(pendingFrom)) + { + clearPending(); + return true; } - else if (!mirror.equals(pendingFrom) || now - pendingDeadline >= 0) + if (now - pendingDeadline >= 0) { + failedForCurrentAttack = true; clearPending(); } + return false; } boolean canDispatch(long now) { - if (aimedForCurrentAttack) + if (aimedForCurrentAttack || failedForCurrentAttack) { return false; } if (pendingExpected != null && now - pendingDeadline >= 0) { + failedForCurrentAttack = true; clearPending(); + return false; } return pendingExpected == null; } @@ -258,6 +269,7 @@ void reset() activeWardrobe = null; activeCycle = Long.MIN_VALUE; aimedForCurrentAttack = false; + failedForCurrentAttack = false; clearPending(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java index d4d4f56baf..84f8b60275 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java @@ -212,8 +212,11 @@ private boolean handleMirrorShowdown() return false; } - mirrorAttackState.observe( - snapshot.mirrorTile, snapshot.wardrobeTile, snapshot.attackCycle, now); + if (mirrorAttackState.observe( + snapshot.mirrorTile, snapshot.wardrobeTile, snapshot.attackCycle, now)) + { + nextMirrorPushAt = 0; + } if (snapshot.wardrobeTile == null || !mirrorAttackState.canDispatch(now)) { return false; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java index 43ddc0a525..62ea9a7c89 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java @@ -83,7 +83,7 @@ public void confirmedFinalPushSuppressesDuplicatesUntilTheAttackCycleEnds() } @Test - public void unobservedPushCanBeRetriedAfterItsDeadline() + public void unobservedPushIsNotRepeatedDuringTheSameAttack() { MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); @@ -91,11 +91,32 @@ public void unobservedPushCanBeRetriedAfterItsDeadline() MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( mirror, wardrobe, ignored -> true); - state.observe(mirror, wardrobe, 1_000L); + state.observe(mirror, wardrobe, 1L, 1_000L); state.recordDispatch(mirror, plan, 1_000L, 500L); assertFalse(state.canDispatch(1_499L)); - assertTrue(state.canDispatch(1_500L)); + state.observe(mirror, wardrobe, 1L, 1_500L); + assertFalse(state.canDispatch(1_500L)); + + state.observe(mirror, wardrobe, 2L, 2_000L); + assertTrue(state.canDispatch(2_000L)); + } + + @Test + public void acknowledgedNonFinalPushImmediatelyAllowsTheNextMove() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(14, 12); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, ignored -> true); + + state.observe(mirror, wardrobe, 1L, 1_000L); + state.recordDispatch(mirror, plan, 1_000L, 1_800L); + + assertTrue(state.observe( + plan.getExpectedMirrorTile(), wardrobe, 1L, 1_600L)); + assertTrue(state.canDispatch(1_600L)); } @Test From 25ff0e6da71169a13a1e96c3be8a92d6cfed06de Mon Sep 17 00:00:00 2001 From: itsbotzilla <25913563+itsBOTzilla@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:50:01 -0500 Subject: [PATCH 4/5] Fix Misthalin mirror timing and combat action --- .../questhelper/QuestHelperPlugin.java | 2 +- .../microbot/questhelper/QuestScript.java | 55 +++++++++++++-- .../microbot/questhelper/logic/IQuest.java | 8 +++ .../logic/MisthalinMirrorPlanner.java | 67 +++++++++++++++++-- .../questhelper/logic/MisthalinMystery.java | 29 +++++++- .../QuestCustomLogicLifecycleTest.java | 11 +++ .../questhelper/QuestShopAutomationTest.java | 12 ++++ .../logic/MisthalinMirrorPlannerTest.java | 54 ++++++++++----- 8 files changed, 205 insertions(+), 33 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index 39e34a4754..cd43e55ebc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -79,7 +79,7 @@ @PluginDescriptor( name = "Quest Helper", - version = "1.0.11", + version = "1.0.12", description = "Helps you with questing", tags = { "quest", "helper", "overlay" } ) 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 2657e15dad..fc750f8952 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 @@ -185,7 +185,9 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } } - if (!Rs2Dialogue.isInDialogue() && (pendingInteraction != null || Rs2Player.isAnimating())) return; + if (shouldPauseBeforeCustomLogic( + Rs2Dialogue.isInDialogue(), pendingInteraction != null, Rs2Player.isAnimating(), + customLogicRunsWhileAnimating())) return; if (questStep != null && !questStep.getWidgetsToHighlight().isEmpty()) { var visibleWidgetHighlights = questStep.getWidgetsToHighlight().stream() @@ -309,10 +311,15 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } } - if (pendingInteraction != null || Rs2Player.isAnimating()) return; + if (pendingInteraction != null) return; + + boolean playerAnimating = Rs2Player.isAnimating(); + if (playerAnimating && !customLogicRunsWhileAnimating()) return; if (!runIdleCustomLogic(questStep)) return; + if (playerAnimating) return; + boolean isInCutscene = Microbot.getVarbitValue(4606) > 0; if (isInCutscene) { if (ShortestPathPlugin.getMarker() != null) @@ -1407,11 +1414,29 @@ private boolean runIdleCustomLogic(QuestStep step) { long now = System.nanoTime(); if (lastCustomStep == step && now - nextCustomAttemptAt < 0) return !customActionPending; lastCustomStep = step; - nextCustomAttemptAt = now + 600_000_000L; + nextCustomAttemptAt = now + customLogicIntervalNanos(); customActionPending = !executeQuestCustomLogic(); return !customActionPending; } + private long customLogicIntervalNanos() { + var questLogic = QuestRegistry.getQuest( + getQuestHelperPlugin().getSelectedQuest().getQuest().getId()); + return questLogic == null ? 600_000_000L + : Math.max(0, questLogic.customLogicIntervalNanos()); + } + + private boolean customLogicRunsWhileAnimating() { + var questLogic = QuestRegistry.getQuest( + getQuestHelperPlugin().getSelectedQuest().getQuest().getId()); + return questLogic != null && questLogic.customLogicRunsWhileAnimating(); + } + + static boolean shouldPauseBeforeCustomLogic(boolean inDialogue, boolean pending, + boolean animating, boolean allowWhileAnimating) { + return !inDialogue && (pending || (animating && !allowWhileAnimating)); + } + private void clearInteractionState() { pendingInteraction = null; unreachableTarget = false; @@ -1661,8 +1686,9 @@ private boolean dispatchNpcStep(NpcStep step) { Rs2Walker.clearWalkingRoute("quest-helper:npc-step-visible-interact"); if (step.getText().stream().anyMatch(x -> x.toLowerCase().contains("kill"))) { - if (!Rs2Combat.inCombat() && npc.click("Attack")) { - beginInteraction(step, npc.getId(), npc.getIndex(), npc.getWorldLocation(), "Attack", -1, 0); + String action = chooseCombatNpcAction(getNpcActions(npc)); + if (!Rs2Combat.inCombat() && npc.click(action)) { + beginInteraction(step, npc.getId(), npc.getIndex(), npc.getWorldLocation(), action, -1, 0); return true; } return false; @@ -1897,6 +1923,25 @@ private String chooseCorrectNPCOption(QuestStep step, Rs2NpcModel npc) { }).orElse(""); } + private String[] getNpcActions(Rs2NpcModel npc) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + NPCComposition composition = Microbot.getClient().getNpcDefinition(npc.getId()); + if (composition != null && composition.getConfigs() != null) composition = composition.transform(); + return composition == null ? null : composition.getActions(); + }).orElse(null); + } + + static String chooseCombatNpcAction(String[] actions) { + if (actions != null) { + for (String preferred : new String[]{"Attack", "Fight"}) { + for (String action : actions) { + if (preferred.equalsIgnoreCase(action)) return action; + } + } + } + return "Attack"; + } + static String chooseNpcAction(List stepText, String[] actions, boolean shopStep) { if (actions == null) { return "Talk-to"; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java index 3a2065b803..f54bc33ee8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/IQuest.java @@ -5,6 +5,14 @@ public interface IQuest { boolean executeCustomLogic(); + default long customLogicIntervalNanos() { + return 600_000_000L; + } + + default boolean customLogicRunsWhileAnimating() { + return false; + } + default boolean onGraphicsObjectCreated(GraphicsObject graphicsObject) { return false; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java index efa981981e..4d6cd7a6f9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java @@ -10,23 +10,48 @@ private MisthalinMirrorPlanner() { } - static PushPlan nextPush(SceneTile mirror, SceneTile wardrobe, + static PushPlan nextPush(SceneTile mirror, SceneTile wardrobe, SceneTile arenaCenter, Predicate canOccupy) { - if (mirror == null || wardrobe == null || canOccupy == null || mirror.equals(wardrobe)) + if (mirror == null || wardrobe == null || arenaCenter == null || canOccupy == null + || mirror.equals(wardrobe)) { return null; } - int deltaX = wardrobe.getX() - mirror.getX(); - int deltaY = wardrobe.getY() - mirror.getY(); + Direction inward = directionToward(wardrobe, arenaCenter); + if (inward == null) + { + return null; + } + + SceneTile target = wardrobe.translate( + inward.getDeltaX() * 4, inward.getDeltaY() * 4); + SceneTile staging = target.translate(inward.getDeltaX(), inward.getDeltaY()); + if (mirror.equals(staging)) + { + return validPlan(mirror, inward.opposite(), true, canOccupy); + } + + return planToward(mirror, staging, canOccupy); + } + + private static PushPlan planToward(SceneTile mirror, SceneTile target, + Predicate canOccupy) + { + int deltaX = target.getX() - mirror.getX(); + int deltaY = target.getY() - mirror.getY(); + if (deltaX == 0 && deltaY == 0) + { + return null; + } if (deltaX == 0) { - return validPlan(mirror, Direction.vertical(deltaY), true, canOccupy); + return validPlan(mirror, Direction.vertical(deltaY), false, canOccupy); } if (deltaY == 0) { - return validPlan(mirror, Direction.horizontal(deltaX), true, canOccupy); + return validPlan(mirror, Direction.horizontal(deltaX), false, canOccupy); } Direction first; @@ -46,6 +71,19 @@ static PushPlan nextPush(SceneTile mirror, SceneTile wardrobe, return plan != null ? plan : validPlan(mirror, second, false, canOccupy); } + private static Direction directionToward(SceneTile from, SceneTile to) + { + int deltaX = to.getX() - from.getX(); + int deltaY = to.getY() - from.getY(); + if (deltaX == 0 && deltaY == 0) + { + return null; + } + return Math.abs(deltaX) > Math.abs(deltaY) + ? Direction.horizontal(deltaX) + : Direction.vertical(deltaY); + } + private static PushPlan validPlan(SceneTile mirror, Direction direction, boolean finalAim, Predicate canOccupy) { @@ -91,6 +129,23 @@ static Direction vertical(int delta) { return delta > 0 ? NORTH : SOUTH; } + + Direction opposite() + { + switch (this) + { + case NORTH: + return SOUTH; + case EAST: + return WEST; + case SOUTH: + return NORTH; + case WEST: + return EAST; + default: + throw new IllegalStateException("Unknown direction " + this); + } + } } static final class SceneTile diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java index 84f8b60275..4c4119b2be 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java @@ -33,6 +33,7 @@ public class MisthalinMystery extends BaseQuest private static final long DAMAGED_WALL_INTERACT_RETRY_NANOS = 1_500_000_000L; private static final long MIRROR_MOVE_RETRY_NANOS = 1_200_000_000L; private static final long MIRROR_PUSH_RETRY_NANOS = 1_800_000_000L; + private static final long MIRROR_LOGIC_INTERVAL_NANOS = 200_000_000L; private static final String MIRROR_SHOWDOWN_MARKER = "move the mirror to reflect the knives"; private static final String LACEY_INTERRUPT_QUESTION = "Interrupt with answer?"; private static final String LACEY_INTERRUPT_ANSWER = "Count Check"; @@ -54,6 +55,8 @@ public class MisthalinMystery extends BaseQuest new WorldPoint(1633, 4837, 0), new WorldPoint(1641, 4828, 0), new WorldPoint(1646, 4836, 0)); + private static final MisthalinMirrorPlanner.SceneTile MIRROR_ARENA_CENTER = + new MisthalinMirrorPlanner.SceneTile(47, 54); private final QuestApproachSequence approachSequence = new QuestApproachSequence(); private final MisthalinMirrorPlanner.AttackState mirrorAttackState = @@ -160,6 +163,26 @@ && isMirrorShowdownText(((DetailedQuestStep) step).getText())) return false; } + @Override + public long customLogicIntervalNanos() + { + return MIRROR_LOGIC_INTERVAL_NANOS; + } + + @Override + public boolean customLogicRunsWhileAnimating() + { + QuestHelperPlugin plugin = getQuestHelperPlugin(); + if (plugin == null || plugin.getSelectedQuest() == null + || plugin.getSelectedQuest().getCurrentStep() == null) + { + return false; + } + QuestStep step = plugin.getSelectedQuest().getCurrentStep().getActiveStep(); + return step instanceof DetailedQuestStep + && isMirrorShowdownText(((DetailedQuestStep) step).getText()); + } + @Override public void reset() { @@ -223,7 +246,7 @@ private boolean handleMirrorShowdown() } MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - snapshot.mirrorTile, snapshot.wardrobeTile, + snapshot.mirrorTile, snapshot.wardrobeTile, MIRROR_ARENA_CENTER, tile -> isWalkableSceneTile(tile, snapshot.worldViewId)); if (plan == null) { @@ -233,7 +256,7 @@ private boolean handleMirrorShowdown() if (!plan.getStandTile().equals(snapshot.playerTile)) { nextMirrorPushAt = 0; - if (Rs2Player.isMoving() || Rs2Player.isAnimating()) + if (Rs2Player.isMoving()) { return false; } @@ -262,7 +285,7 @@ private boolean handleMirrorShowdown() mirrorMoveTarget = null; nextMirrorMoveAt = 0; - if (Rs2Player.isMoving() || Rs2Player.isAnimating() + if (Rs2Player.isMoving() || now - nextMirrorPushAt < 0) { return false; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestCustomLogicLifecycleTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestCustomLogicLifecycleTest.java index 75b23454f6..e67ef6483e 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestCustomLogicLifecycleTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestCustomLogicLifecycleTest.java @@ -9,10 +9,21 @@ import org.objectweb.asm.tree.MethodNode; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; public class QuestCustomLogicLifecycleTest { + @Test + public void onlyOptedInCustomLogicRunsDuringAnimation() + { + assertTrue(QuestScript.shouldPauseBeforeCustomLogic(false, false, true, false)); + assertFalse(QuestScript.shouldPauseBeforeCustomLogic(false, false, true, true)); + assertTrue(QuestScript.shouldPauseBeforeCustomLogic(false, true, true, true)); + assertFalse(QuestScript.shouldPauseBeforeCustomLogic(true, false, true, false)); + } + @Test public void clearingInteractionStateAlsoClearsCustomQuestState() throws Exception { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestShopAutomationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestShopAutomationTest.java index 9f8f617687..593910e17d 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestShopAutomationTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/QuestShopAutomationTest.java @@ -30,6 +30,18 @@ public void ordinaryNpcStepStillPrefersMatchingTalkAction() { assertEquals("Talk-to", action); } + @Test + public void combatNpcUsesFightWhenAttackIsNotAvailable() { + assertEquals("Fight", QuestScript.chooseCombatNpcAction( + new String[]{"Talk-to", "Fight", null})); + } + + @Test + public void combatNpcStillPrefersAttackWhenAvailable() { + assertEquals("Attack", QuestScript.chooseCombatNpcAction( + new String[]{"Talk-to", "Fight", "Attack"})); + } + @Test public void shopStepSelectsFirstItemNotAlreadyInInventory() { IntPredicate hasItem = itemId -> itemId == 100; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java index 62ea9a7c89..8c2b33dba9 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java @@ -14,26 +14,32 @@ public class MisthalinMirrorPlannerTest { @Test - public void alignedMirrorIsPushedTowardTheAttackingWardrobe() + public void misthalinCustomLogicUsesTheQuestSchedulerCadence() + { + assertEquals(200_000_000L, new MisthalinMystery().customLogicIntervalNanos()); + } + + @Test + public void stagingTileIsPushedTowardTheWardrobeOntoTheFourthTile() { MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - tile(10, 10), tile(10, 14), ignored -> true); + tile(10, 15), tile(10, 10), tile(10, 20), ignored -> true); - assertEquals(MisthalinMirrorPlanner.Direction.NORTH, plan.getDirection()); - assertEquals(tile(10, 9), plan.getStandTile()); - assertEquals(tile(10, 11), plan.getExpectedMirrorTile()); + assertEquals(MisthalinMirrorPlanner.Direction.SOUTH, plan.getDirection()); + assertEquals(tile(10, 16), plan.getStandTile()); + assertEquals(tile(10, 14), plan.getExpectedMirrorTile()); assertTrue(plan.isFinalAim()); } @Test - public void unalignedMirrorUsesTheShorterAxisToReachAnAttackLane() + public void mirrorRoutesToTheStagingTileBeforeTheFinalAim() { MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - tile(10, 10), tile(14, 12), ignored -> true); + tile(8, 14), tile(10, 10), tile(10, 20), ignored -> true); assertEquals(MisthalinMirrorPlanner.Direction.NORTH, plan.getDirection()); - assertEquals(tile(10, 9), plan.getStandTile()); - assertEquals(tile(10, 11), plan.getExpectedMirrorTile()); + assertEquals(tile(8, 13), plan.getStandTile()); + assertEquals(tile(8, 15), plan.getExpectedMirrorTile()); assertFalse(plan.isFinalAim()); } @@ -44,7 +50,8 @@ public void blockedPreferredAxisFallsBackToTheOtherAlignmentLane() blocked.add(tile(10, 9)); MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - tile(10, 10), tile(14, 12), point -> !blocked.contains(point)); + tile(10, 10), tile(14, 12), tile(20, 12), + point -> !blocked.contains(point)); assertEquals(MisthalinMirrorPlanner.Direction.EAST, plan.getDirection()); assertEquals(tile(9, 10), plan.getStandTile()); @@ -55,7 +62,18 @@ public void blockedPreferredAxisFallsBackToTheOtherAlignmentLane() public void plannerRejectsPushesWithoutAValidStandAndDestination() { assertNull(MisthalinMirrorPlanner.nextPush( - tile(10, 10), tile(14, 12), ignored -> false)); + tile(10, 10), tile(14, 12), tile(20, 12), ignored -> false)); + } + + @Test + public void mirrorOnTheFourthTileMovesOutwardBeforeBeingAimedBackOntoIt() + { + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + tile(10, 14), tile(10, 10), tile(10, 20), ignored -> true); + + assertEquals(MisthalinMirrorPlanner.Direction.NORTH, plan.getDirection()); + assertEquals(tile(10, 15), plan.getExpectedMirrorTile()); + assertFalse(plan.isFinalAim()); } @Test @@ -63,9 +81,9 @@ public void confirmedFinalPushSuppressesDuplicatesUntilTheAttackCycleEnds() { MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); - MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 14); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 5); MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - mirror, wardrobe, ignored -> true); + mirror, wardrobe, tile(10, 20), ignored -> true); state.observe(mirror, wardrobe, 1_000L); assertTrue(state.canDispatch(1_000L)); @@ -87,9 +105,9 @@ public void unobservedPushIsNotRepeatedDuringTheSameAttack() { MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); - MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 14); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 5); MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - mirror, wardrobe, ignored -> true); + mirror, wardrobe, tile(10, 20), ignored -> true); state.observe(mirror, wardrobe, 1L, 1_000L); state.recordDispatch(mirror, plan, 1_000L, 500L); @@ -109,7 +127,7 @@ public void acknowledgedNonFinalPushImmediatelyAllowsTheNextMove() MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); MisthalinMirrorPlanner.SceneTile wardrobe = tile(14, 12); MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - mirror, wardrobe, ignored -> true); + mirror, wardrobe, tile(20, 12), ignored -> true); state.observe(mirror, wardrobe, 1L, 1_000L); state.recordDispatch(mirror, plan, 1_000L, 1_800L); @@ -124,9 +142,9 @@ public void repeatedGraphicAtSameWardrobeStartsANewAttackCycle() { MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); - MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 14); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 5); MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( - mirror, wardrobe, ignored -> true); + mirror, wardrobe, tile(10, 20), ignored -> true); state.observe(mirror, wardrobe, 1L, 1_000L); state.recordDispatch(mirror, plan, 1_000L, 500L); From 7841d3bad0ea78fdc600638252aef9316eebaf6f Mon Sep 17 00:00:00 2001 From: itsbotzilla <25913563+itsBOTzilla@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:07:58 -0500 Subject: [PATCH 5/5] Fix Misthalin sapphire room exit --- .../questhelper/QuestHelperPlugin.java | 2 +- .../questhelper/logic/MisthalinMystery.java | 82 ++++++++++++++ .../logic/MisthalinApproachSequenceTest.java | 100 ++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index cd43e55ebc..0b3e55d1c6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -79,7 +79,7 @@ @PluginDescriptor( name = "Quest Helper", - version = "1.0.12", + version = "1.0.13", description = "Helps you with questing", tags = { "quest", "helper", "overlay" } ) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java index 4c4119b2be..25ace23a81 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMystery.java @@ -11,7 +11,9 @@ import net.runelite.api.Player; import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.ObjectID; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; @@ -19,8 +21,10 @@ import net.runelite.client.plugins.microbot.questhelper.steps.ObjectStep; import net.runelite.client.plugins.microbot.questhelper.steps.QuestStep; 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.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.input.InputArbiter; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; 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; @@ -31,6 +35,7 @@ public class MisthalinMystery extends BaseQuest { private static final long DAMAGED_WALL_CANVAS_RETRY_NANOS = 1_500_000_000L; private static final long DAMAGED_WALL_INTERACT_RETRY_NANOS = 1_500_000_000L; + private static final long SAPPHIRE_EXIT_RETRY_NANOS = 600_000_000L; private static final long MIRROR_MOVE_RETRY_NANOS = 1_200_000_000L; private static final long MIRROR_PUSH_RETRY_NANOS = 1_800_000_000L; private static final long MIRROR_LOGIC_INTERVAL_NANOS = 200_000_000L; @@ -55,6 +60,8 @@ public class MisthalinMystery extends BaseQuest new WorldPoint(1633, 4837, 0), new WorldPoint(1641, 4828, 0), new WorldPoint(1646, 4836, 0)); + private static final WorldPoint SAPPHIRE_DOOR = new WorldPoint(1628, 4829, 0); + private static final String ATTEMPT_SAPPHIRE_EXIT = "attempt to go through the sapphire door"; private static final MisthalinMirrorPlanner.SceneTile MIRROR_ARENA_CENTER = new MisthalinMirrorPlanner.SceneTile(47, 54); @@ -66,6 +73,7 @@ public class MisthalinMystery extends BaseQuest private volatile long nextDamagedWallLocalAt; private volatile long nextDamagedWallCanvasAt; private volatile long nextDamagedWallInteractAt; + private volatile long nextSapphireExitAt; private volatile long nextMirrorMoveAt; private volatile long nextMirrorPushAt; private MisthalinMirrorPlanner.SceneTile mirrorMoveTarget; @@ -80,6 +88,7 @@ public boolean executeCustomLogic() { approachSequence.reset(); resetDamagedWallApproach(); + resetSapphireExit(); resetMirrorShowdown(); return true; } @@ -110,6 +119,36 @@ && isMirrorShowdownText(((DetailedQuestStep) step).getText())) DetailedQuestStep detailedStep = (DetailedQuestStep) step; WorldPoint objectLocation = detailedStep.getDefinedPoint() == null ? null : detailedStep.getDefinedPoint().getWorldPoint(); + if (isSapphireExitStep(objectLocation, detailedStep.getText())) + { + approachSequence.reset(); + resetDamagedWallApproach(); + return handleSapphireExit( + Rs2Player.isMoving(), + Rs2Equipment.isWearing(ItemID.MISTMYST_CUTSCENE_KNIFE), + Rs2Inventory.hasItem(ItemID.MISTMYST_CUTSCENE_KNIFE), + () -> { + Rs2Walker.clearWalkingRoute("quest-helper:misthalin-sapphire-exit-unequip"); + return Rs2Equipment.unEquip(ItemID.MISTMYST_CUTSCENE_KNIFE); + }, + () -> { + Rs2Walker.clearWalkingRoute("quest-helper:misthalin-sapphire-exit-drop-knife"); + return Rs2Inventory.drop(ItemID.MISTMYST_CUTSCENE_KNIFE); + }, + () -> { + var door = Microbot.getRs2TileObjectCache().query() + .fromWorldView() + .withId(ObjectID.MISTMYST_DOOR_SAPPHIRE) + .firstOnClientThread(); + if (door == null) + { + return false; + } + Rs2Walker.clearWalkingRoute("quest-helper:misthalin-sapphire-exit-open-door"); + return door.click("Open"); + }); + } + resetSapphireExit(); List route = approachRoute(objectLocation, detailedStep.getText()); if (route.isEmpty()) { @@ -188,6 +227,7 @@ public void reset() { approachSequence.reset(); resetDamagedWallApproach(); + resetSapphireExit(); resetMirrorShowdown(); } @@ -219,6 +259,43 @@ static boolean isMirrorShowdownText(List text) .anyMatch(line -> line.contains(MIRROR_SHOWDOWN_MARKER)); } + static boolean isSapphireExitStep(WorldPoint objectLocation, List text) + { + return SAPPHIRE_DOOR.equals(objectLocation) && text != null && text.stream() + .filter(line -> line != null) + .map(line -> line.toLowerCase(Locale.ENGLISH)) + .anyMatch(line -> line.contains(ATTEMPT_SAPPHIRE_EXIT)); + } + + boolean handleSapphireExit(boolean moving, boolean knifeEquipped, boolean knifeInInventory, + BooleanSupplier unequip, BooleanSupplier drop, + BooleanSupplier openDoor) + { + if (moving) + { + return false; + } + long now = System.nanoTime(); + if (nextSapphireExitAt != 0 && now - nextSapphireExitAt < 0) + { + return false; + } + nextSapphireExitAt = now + SAPPHIRE_EXIT_RETRY_NANOS; + if (knifeEquipped) + { + unequip.getAsBoolean(); + } + else if (knifeInInventory) + { + drop.getAsBoolean(); + } + else + { + openDoor.getAsBoolean(); + } + return false; + } + private boolean handleMirrorShowdown() { if (!mirrorShowdownActive) @@ -507,6 +584,11 @@ private void resetDamagedWallApproach() nextDamagedWallInteractAt = 0; } + private void resetSapphireExit() + { + nextSapphireExitAt = 0; + } + static boolean useCanvas(WorldPoint waypoint) { return PAINTING_CANVAS_ENTRY.equals(waypoint) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinApproachSequenceTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinApproachSequenceTest.java index 8672b25bb3..81ee57748c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinApproachSequenceTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinApproachSequenceTest.java @@ -175,6 +175,106 @@ public void damagedWallApproachKeepsControlUntilThePlayerCanClimb() throws Excep assertEquals("The same wall interaction must not be dispatched every poll", 1, climbs[0]); } + @Test + public void sapphireExitRemovesAndDropsTheKillerKnifeBeforeOpeningTheDoor() throws Exception + { + Method exit; + try + { + exit = MisthalinMystery.class.getDeclaredMethod("handleSapphireExit", + boolean.class, boolean.class, boolean.class, + BooleanSupplier.class, BooleanSupplier.class, BooleanSupplier.class); + exit.setAccessible(true); + } + catch (NoSuchMethodException ex) + { + fail("The Misthalin sapphire-room exit must own weapon cleanup and door interaction"); + return; + } + + int[] unequips = {0}; + int[] drops = {0}; + int[] opens = {0}; + BooleanSupplier unequip = () -> { + unequips[0]++; + return true; + }; + BooleanSupplier drop = () -> { + drops[0]++; + return true; + }; + BooleanSupplier open = () -> { + opens[0]++; + return true; + }; + + assertFalse((boolean) exit.invoke(new MisthalinMystery(), + false, true, false, unequip, drop, open)); + assertEquals(1, unequips[0]); + assertEquals(0, drops[0]); + assertEquals(0, opens[0]); + + assertFalse((boolean) exit.invoke(new MisthalinMystery(), + false, false, true, unequip, drop, open)); + assertEquals(1, unequips[0]); + assertEquals(1, drops[0]); + assertEquals(0, opens[0]); + + assertFalse((boolean) exit.invoke(new MisthalinMystery(), + false, false, false, unequip, drop, open)); + assertEquals(1, unequips[0]); + assertEquals(1, drops[0]); + assertEquals(1, opens[0]); + + assertFalse((boolean) exit.invoke(new MisthalinMystery(), + true, false, false, unequip, drop, open)); + assertEquals("Movement must suppress an extra door click", 1, opens[0]); + + int[] throttledOpens = {0}; + BooleanSupplier throttledOpen = () -> { + throttledOpens[0]++; + return false; + }; + MisthalinMystery throttled = new MisthalinMystery(); + assertFalse((boolean) exit.invoke(throttled, + false, false, false, unequip, drop, throttledOpen)); + assertFalse((boolean) exit.invoke(throttled, + false, false, false, unequip, drop, throttledOpen)); + assertEquals("A failed door action must not be repeated every 200ms poll", 1, throttledOpens[0]); + + Field nextAttempt = MisthalinMystery.class.getDeclaredField("nextSapphireExitAt"); + nextAttempt.setAccessible(true); + nextAttempt.setLong(throttled, 0L); + assertFalse((boolean) exit.invoke(throttled, + false, false, false, unequip, drop, throttledOpen)); + assertEquals("The door action must retry after its throttle expires", 2, throttledOpens[0]); + } + + @Test + public void sapphireExitHandlerOnlyClaimsTheAttemptToLeaveStep() throws Exception + { + Method matches; + try + { + matches = MisthalinMystery.class.getDeclaredMethod( + "isSapphireExitStep", WorldPoint.class, List.class); + matches.setAccessible(true); + } + catch (NoSuchMethodException ex) + { + fail("The sapphire exit handler must not take over the earlier entry-door steps"); + return; + } + + WorldPoint sapphireDoor = new WorldPoint(1628, 4829, 0); + assertTrue((boolean) matches.invoke(null, sapphireDoor, + List.of("Attempt to go through the sapphire door."))); + assertFalse((boolean) matches.invoke(null, sapphireDoor, + List.of("Go through the sapphire door."))); + assertFalse((boolean) matches.invoke(null, new WorldPoint(1635, 4838, 0), + List.of("Attempt to go through the sapphire door."))); + } + @Test public void misthalinCustomRouteHandlerIsRegistered() {