diff --git a/runelite-api/src/main/java/net/runelite/api/GraphicID.java b/runelite-api/src/main/java/net/runelite/api/GraphicID.java index 669cb1e441..6f2db34f74 100644 --- a/runelite-api/src/main/java/net/runelite/api/GraphicID.java +++ b/runelite-api/src/main/java/net/runelite/api/GraphicID.java @@ -44,7 +44,6 @@ public class GraphicID public static final int BOOK_HOME_TELEPORT_3 = 803; public static final int BOOK_HOME_TELEPORT_4 = 804; public static final int STAFF_OF_THE_DEAD = 1228; - public static final int IMBUED_HEART = 1316; public static final int FLYING_FISH = 1387; public static final int NPC_CONTACT = 728; public static final int POT_SHARE = 733; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java b/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java index e4521feb9e..2008152b56 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java @@ -54,7 +54,7 @@ enum GameTimer ICEBURST(SpriteID.SPELL_ICE_BURST, GameTimerImageType.SPRITE, "Ice burst", GraphicID.ICE_BURST, 16, GAME_TICKS, true), ICEBLITZ(SpriteID.SPELL_ICE_BLITZ, GameTimerImageType.SPRITE, "Ice blitz", GraphicID.ICE_BLITZ, 24, GAME_TICKS, true), ICEBARRAGE(SpriteID.SPELL_ICE_BARRAGE, GameTimerImageType.SPRITE, "Ice barrage", GraphicID.ICE_BARRAGE, 32, GAME_TICKS, true), - IMBUEDHEART(ItemID.IMBUED_HEART, GameTimerImageType.ITEM, "Imbued heart", GraphicID.IMBUED_HEART, 420, ChronoUnit.SECONDS, true), + IMBUEDHEART(ItemID.IMBUED_HEART, GameTimerImageType.ITEM, "Imbued heart", 420, ChronoUnit.SECONDS, true), VENGEANCE(SpriteID.SPELL_VENGEANCE, GameTimerImageType.SPRITE, "Vengeance", 30, ChronoUnit.SECONDS), EXSUPERANTIFIRE(ItemID.EXTENDED_SUPER_ANTIFIRE4, GameTimerImageType.ITEM, "Extended Super AntiFire", 6, ChronoUnit.MINUTES), OVERLOAD_RAID(ItemID.OVERLOAD_4_20996, GameTimerImageType.ITEM, "Overload", 5, ChronoUnit.MINUTES, true), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java index 341c1c35a0..3fafb14126 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java @@ -39,6 +39,7 @@ import net.runelite.api.ChatMessageType; import net.runelite.api.Client; import net.runelite.api.Constants; import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.GameState; import net.runelite.api.InventoryID; import net.runelite.api.Item; import net.runelite.api.ItemContainer; @@ -48,11 +49,13 @@ import static net.runelite.api.ItemID.INFERNAL_CAPE; import net.runelite.api.NPC; import net.runelite.api.NpcID; import net.runelite.api.Player; +import net.runelite.api.Skill; import net.runelite.api.VarPlayer; import net.runelite.api.Varbits; import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.ActorDeath; import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.StatChanged; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.GameStateChanged; import net.runelite.api.events.GameTick; @@ -141,6 +144,8 @@ public class TimersPlugin extends Plugin private int lastAnimation; private boolean widgetHiddenChangedOnPvpWorld; private ElapsedTimer tzhaarTimer; + private int imbuedHeartClickTick = -1; + private int lastBoostedMagicLevel = -1; @Inject private ItemManager itemManager; @@ -163,6 +168,15 @@ public class TimersPlugin extends Plugin return configManager.getConfig(TimersConfig.class); } + @Override + public void startUp() + { + if (client.getGameState() == GameState.LOGGED_IN) + { + lastBoostedMagicLevel = client.getBoostedSkillLevel(Skill.MAGIC); + } + } + @Override protected void shutDown() throws Exception { @@ -176,6 +190,8 @@ public class TimersPlugin extends Plugin nextPoisonTick = 0; removeTzhaarTimer(); staminaTimer = null; + imbuedHeartClickTick = -1; + lastBoostedMagicLevel = -1; } @Subscribe @@ -435,6 +451,12 @@ public class TimersPlugin extends Plugin return; } + if (event.getMenuOption().contains("Invigorate") + && event.getId() == ItemID.IMBUED_HEART) + { + imbuedHeartClickTick = client.getTickCount(); + } + TeleportWidget teleportWidget = TeleportWidget.of(event.getWidgetId()); if (teleportWidget != null) { @@ -796,8 +818,10 @@ public class TimersPlugin extends Plugin config.tzhaarLastTime(null); } break; - case HOPPING: case LOGIN_SCREEN: + lastBoostedMagicLevel = -1; + // fall through + case HOPPING: // pause tzhaar timer if logged out without pausing if (config.tzhaarStartTime() != null && config.tzhaarLastTime() == null) { @@ -856,11 +880,6 @@ public class TimersPlugin extends Plugin return; } - if (config.showImbuedHeart() && actor.getGraphic() == IMBUEDHEART.getGraphicId()) - { - createGameTimer(IMBUEDHEART); - } - if (config.showFreezes()) { if (actor.getGraphic() == BIND.getGraphicId()) @@ -978,6 +997,37 @@ public class TimersPlugin extends Plugin } } + @Subscribe + public void onStatChanged(StatChanged statChanged) + { + if (statChanged.getSkill() != Skill.MAGIC) + { + return; + } + + final int boostedMagicLevel = statChanged.getBoostedLevel(); + + if (imbuedHeartClickTick < 0 + || client.getTickCount() > imbuedHeartClickTick + 3 // allow for 2 ticks of lag + || !config.showImbuedHeart()) + { + lastBoostedMagicLevel = boostedMagicLevel; + return; + } + + final int boostAmount = boostedMagicLevel - statChanged.getLevel(); + final int boostChange = boostedMagicLevel - lastBoostedMagicLevel; + final int heartBoost = 1 + (statChanged.getLevel() / 10); + + if ((boostAmount == heartBoost || (lastBoostedMagicLevel != -1 && boostChange == heartBoost)) + && boostChange > 0) + { + createGameTimer(IMBUEDHEART); + } + + lastBoostedMagicLevel = boostedMagicLevel; + } + private void createStaminaTimer() { Duration duration = Duration.ofMinutes(wasWearingEndurance ? 4 : 2); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/timers/TimersPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/timers/TimersPluginTest.java index ab7a254c6d..69e2b1eec1 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/timers/TimersPluginTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/timers/TimersPluginTest.java @@ -32,10 +32,15 @@ import java.time.Duration; import java.time.Instant; import net.runelite.api.ChatMessageType; import net.runelite.api.Client; +import net.runelite.api.Experience; import net.runelite.api.InventoryID; import net.runelite.api.ItemContainer; +import net.runelite.api.ItemID; +import net.runelite.api.Skill; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.MenuOptionClicked; +import net.runelite.api.events.StatChanged; import net.runelite.client.game.ItemManager; import net.runelite.client.game.SpriteManager; import net.runelite.client.ui.overlay.infobox.InfoBox; @@ -53,9 +58,11 @@ import static org.mockito.ArgumentMatchers.nullable; import org.mockito.Mock; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import org.mockito.junit.MockitoJUnitRunner; import org.mockito.stubbing.Answer; @@ -369,4 +376,125 @@ public class TimersPluginTest ElapsedTimer timer = (ElapsedTimer) captor.getValue(); assertEquals("00:06", timer.getText()); } -} \ No newline at end of file + + @Test + public void testImbuedHeartBoost() + { + when(timersConfig.showImbuedHeart()).thenReturn(true); + when(client.getTickCount()).thenReturn(100); + StatChanged event; + + final MenuOptionClicked imbuedHeartClick = new MenuOptionClicked(); + imbuedHeartClick.setMenuOption("Invigorate"); + imbuedHeartClick.setId(ItemID.IMBUED_HEART); + timersPlugin.onMenuOptionClicked(imbuedHeartClick); + + when(client.getTickCount()).thenReturn(101); + + for (int level = 1, i = 0; level <= Experience.MAX_REAL_LEVEL; level++, i++) + { + event = new StatChanged(Skill.MAGIC, 0, level, heartBoostedLevel(level)); + timersPlugin.onStatChanged(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InfoBox.class); + verify(infoBoxManager, times(i + 1)).addInfoBox(captor.capture()); + TimerTimer infoBox = (TimerTimer) captor.getValue(); + assertEquals(GameTimer.IMBUEDHEART, infoBox.getTimer()); + } + } + + @Test + public void testImbuedHeartBoostFromDrained() + { + when(timersConfig.showImbuedHeart()).thenReturn(true); + when(client.getTickCount()).thenReturn(100); + + final MenuOptionClicked imbuedHeartClick = new MenuOptionClicked(); + imbuedHeartClick.setMenuOption("Invigorate"); + imbuedHeartClick.setId(ItemID.IMBUED_HEART); + timersPlugin.onMenuOptionClicked(imbuedHeartClick); + + when(client.getTickCount()).thenReturn(101); + + for (int level = 1, i = 0; level <= Experience.MAX_REAL_LEVEL; level++, i++) + { + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, level - 1)); + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, heartBoostedLevel(level) - 1)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InfoBox.class); + verify(infoBoxManager, times(i + 1)).addInfoBox(captor.capture()); + TimerTimer infoBox = (TimerTimer) captor.getValue(); + assertEquals(GameTimer.IMBUEDHEART, infoBox.getTimer()); + } + } + + @Test + public void testImbuedHeartBoostFromPartialBoost() + { + when(timersConfig.showImbuedHeart()).thenReturn(true); + when(client.getTickCount()).thenReturn(100); + + final MenuOptionClicked imbuedHeartClick = new MenuOptionClicked(); + imbuedHeartClick.setMenuOption("Invigorate"); + imbuedHeartClick.setId(ItemID.IMBUED_HEART); + timersPlugin.onMenuOptionClicked(imbuedHeartClick); + + when(client.getTickCount()).thenReturn(101); + + for (int level = 10, i = 0; level <= Experience.MAX_REAL_LEVEL; level++, i++) + { + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, level + 1)); + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, heartBoostedLevel(level))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InfoBox.class); + verify(infoBoxManager, times(i + 1)).addInfoBox(captor.capture()); + TimerTimer infoBox = (TimerTimer) captor.getValue(); + assertEquals(GameTimer.IMBUEDHEART, infoBox.getTimer()); + } + } + + @Test + public void testNonImbuedHeartBoost() + { + lenient().when(timersConfig.showImbuedHeart()).thenReturn(true); + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, 1, 1)); + + // Simulate stat changes of imbued heart boost amount without having clicked the imbued heart + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, 29, 34)); // equal to magic essence + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, 39, 43)); // equal to magic potion + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, 49, 54)); // equal to spicy stew + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, 99, 109)); + + verifyNoInteractions(infoBoxManager); + } + + @Test + public void testMagicLevelDrain() + { + lenient().when(timersConfig.showImbuedHeart()).thenReturn(true); + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, 1, 1)); + when(client.getTickCount()).thenReturn(100); + + final MenuOptionClicked imbuedHeartClick = new MenuOptionClicked(); + imbuedHeartClick.setMenuOption("Invigorate"); + imbuedHeartClick.setId(ItemID.IMBUED_HEART); + timersPlugin.onMenuOptionClicked(imbuedHeartClick); + + when(client.getTickCount()).thenReturn(101); + + // Simulate stat changes draining to the imbued heart boost amount + for (int level = 1; level <= Experience.MAX_REAL_LEVEL; level++) + { + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, level)); + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, heartBoostedLevel(level) + 1)); + timersPlugin.onStatChanged(new StatChanged(Skill.MAGIC, 0, level, heartBoostedLevel(level))); + } + + verifyNoInteractions(infoBoxManager); + } + + private static int heartBoostedLevel(final int level) + { + return level + 1 + (level / 10); + } +}