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..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.8", + version = "1.0.13", 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..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 @@ -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() { @@ -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) @@ -1391,15 +1398,45 @@ 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; 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; @@ -1649,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; @@ -1885,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 1d03ba7a6f..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 @@ -1,8 +1,22 @@ package net.runelite.client.plugins.microbot.questhelper.logic; +import net.runelite.api.GraphicsObject; + public interface IQuest { boolean executeCustomLogic(); + default long customLogicIntervalNanos() { + return 600_000_000L; + } + + default boolean customLogicRunsWhileAnimating() { + return false; + } + + 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 new file mode 100644 index 0000000000..4d6cd7a6f9 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlanner.java @@ -0,0 +1,394 @@ +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, SceneTile arenaCenter, + Predicate canOccupy) + { + if (mirror == null || wardrobe == null || arenaCenter == null || canOccupy == null + || mirror.equals(wardrobe)) + { + return null; + } + + 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), false, canOccupy); + } + if (deltaY == 0) + { + return validPlan(mirror, Direction.horizontal(deltaX), false, 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 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) + { + 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; + } + + 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 + { + 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 long activeCycle = Long.MIN_VALUE; + private long inferredCycle; + private SceneTile pendingFrom; + private SceneTile pendingExpected; + private boolean pendingFinalAim; + private long pendingDeadline; + private boolean aimedForCurrentAttack; + private boolean failedForCurrentAttack; + + boolean observe(SceneTile mirror, SceneTile wardrobe, long now) + { + if (wardrobe == null) + { + return observe(mirror, null, Long.MIN_VALUE, now); + } + if (activeWardrobe == null || !wardrobe.equals(activeWardrobe)) + { + inferredCycle++; + } + return observe(mirror, wardrobe, inferredCycle, now); + } + + boolean observe(SceneTile mirror, SceneTile wardrobe, long cycle, long now) + { + if (wardrobe == null) + { + reset(); + return false; + } + if (cycle != activeCycle || !wardrobe.equals(activeWardrobe)) + { + activeWardrobe = wardrobe; + activeCycle = cycle; + clearPending(); + aimedForCurrentAttack = false; + failedForCurrentAttack = false; + } + if (pendingExpected == null || mirror == null) + { + return false; + } + if (mirror.equals(pendingExpected)) + { + aimedForCurrentAttack = pendingFinalAim; + clearPending(); + return true; + } + if (!mirror.equals(pendingFrom)) + { + clearPending(); + return true; + } + if (now - pendingDeadline >= 0) + { + failedForCurrentAttack = true; + clearPending(); + } + return false; + } + + boolean canDispatch(long now) + { + if (aimedForCurrentAttack || failedForCurrentAttack) + { + return false; + } + if (pendingExpected != null && now - pendingDeadline >= 0) + { + failedForCurrentAttack = true; + clearPending(); + return false; + } + 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; + activeCycle = Long.MIN_VALUE; + aimedForCurrentAttack = false; + failedForCurrentAttack = false; + clearPending(); + } + + private void clearPending() + { + pendingFrom = null; + pendingExpected = null; + pendingFinalAim = false; + 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 efaeee65f9..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 @@ -2,25 +2,44 @@ import java.util.Collections; import java.util.List; +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.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; 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.equipment.Rs2Equipment; 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.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; /** 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; 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; + 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); @@ -41,11 +60,24 @@ 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); 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; + private volatile long nextSapphireExitAt; + private volatile long nextMirrorMoveAt; + private volatile long nextMirrorPushAt; + private MisthalinMirrorPlanner.SceneTile mirrorMoveTarget; + private boolean mirrorShowdownActive; @Override public boolean executeCustomLogic() @@ -56,6 +88,8 @@ public boolean executeCustomLogic() { approachSequence.reset(); resetDamagedWallApproach(); + resetSapphireExit(); + resetMirrorShowdown(); return true; } @@ -70,6 +104,12 @@ public boolean executeCustomLogic() { return false; } + if (step instanceof DetailedQuestStep + && isMirrorShowdownText(((DetailedQuestStep) step).getText())) + { + return handleMirrorShowdown(); + } + resetMirrorShowdown(); if (!(step instanceof ObjectStep)) { approachSequence.reset(); @@ -79,6 +119,36 @@ public boolean executeCustomLogic() 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()) { @@ -132,11 +202,296 @@ public boolean executeCustomLogic() 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() { approachSequence.reset(); resetDamagedWallApproach(); + resetSapphireExit(); + 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() + .filter(line -> line != null) + .map(line -> line.toLowerCase(Locale.ENGLISH)) + .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) + { + 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; + } + + if (mirrorAttackState.observe( + snapshot.mirrorTile, snapshot.wardrobeTile, snapshot.attackCycle, now)) + { + nextMirrorPushAt = 0; + } + if (snapshot.wardrobeTile == null || !mirrorAttackState.canDispatch(now)) + { + return false; + } + + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + snapshot.mirrorTile, snapshot.wardrobeTile, MIRROR_ARENA_CENTER, + tile -> isWalkableSceneTile(tile, snapshot.worldViewId)); + if (plan == null) + { + return false; + } + + if (!plan.getStandTile().equals(snapshot.playerTile)) + { + nextMirrorPushAt = 0; + if (Rs2Player.isMoving()) + { + 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() + || 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; + } + MisthalinMirrorPlanner.WardrobeCue cue = wardrobeCueState.snapshot(); + boolean cueInPlayerWorldView = cue != null + && cue.getWorldViewId() == player.getWorldView().getId(); + return new MirrorSnapshot( + mirror, + sceneTile(player.getLocalLocation()), + sceneTile(mirror.getLocalLocation()), + cueInPlayerWorldView ? cue.getTile() : null, + cueInPlayerWorldView ? cue.getCycle() : Long.MIN_VALUE, + 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(); + wardrobeCueState.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 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; + } } static List approachRoute(WorldPoint objectLocation, List stepText) @@ -229,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/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/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() { 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..8c2b33dba9 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/questhelper/logic/MisthalinMirrorPlannerTest.java @@ -0,0 +1,206 @@ +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 misthalinCustomLogicUsesTheQuestSchedulerCadence() + { + assertEquals(200_000_000L, new MisthalinMystery().customLogicIntervalNanos()); + } + + @Test + public void stagingTileIsPushedTowardTheWardrobeOntoTheFourthTile() + { + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + tile(10, 15), tile(10, 10), tile(10, 20), ignored -> true); + + assertEquals(MisthalinMirrorPlanner.Direction.SOUTH, plan.getDirection()); + assertEquals(tile(10, 16), plan.getStandTile()); + assertEquals(tile(10, 14), plan.getExpectedMirrorTile()); + assertTrue(plan.isFinalAim()); + } + + @Test + public void mirrorRoutesToTheStagingTileBeforeTheFinalAim() + { + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + tile(8, 14), tile(10, 10), tile(10, 20), ignored -> true); + + assertEquals(MisthalinMirrorPlanner.Direction.NORTH, plan.getDirection()); + assertEquals(tile(8, 13), plan.getStandTile()); + assertEquals(tile(8, 15), 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), tile(20, 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), 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 + public void confirmedFinalPushSuppressesDuplicatesUntilTheAttackCycleEnds() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 5); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, tile(10, 20), 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 unobservedPushIsNotRepeatedDuringTheSameAttack() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 5); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, tile(10, 20), ignored -> true); + + state.observe(mirror, wardrobe, 1L, 1_000L); + state.recordDispatch(mirror, plan, 1_000L, 500L); + + assertFalse(state.canDispatch(1_499L)); + 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, tile(20, 12), 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 + public void repeatedGraphicAtSameWardrobeStartsANewAttackCycle() + { + MisthalinMirrorPlanner.AttackState state = new MisthalinMirrorPlanner.AttackState(); + MisthalinMirrorPlanner.SceneTile mirror = tile(10, 10); + MisthalinMirrorPlanner.SceneTile wardrobe = tile(10, 5); + MisthalinMirrorPlanner.PushPlan plan = MisthalinMirrorPlanner.nextPush( + mirror, wardrobe, tile(10, 20), 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() + { + 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); + } +}