From 0500f9483024a823a24597326cd7028d5705a8a9 Mon Sep 17 00:00:00 2001 From: LlemonDuck Date: Sat, 7 May 2022 14:19:13 -0700 Subject: [PATCH] timetracking: add compost tracking --- .../net/runelite/api/widgets/WidgetID.java | 1 + .../net/runelite/api/widgets/WidgetInfo.java | 1 + .../timetracking/TimeTrackingConfig.java | 1 + .../timetracking/TimeTrackingPlugin.java | 12 + .../plugins/timetracking/TimeablePanel.java | 41 ++- .../timetracking/farming/CompostState.java | 43 +++ .../timetracking/farming/CompostTracker.java | 293 ++++++++++++++++ .../timetracking/farming/FarmingPatch.java | 5 + .../timetracking/farming/FarmingTabPanel.java | 27 +- .../timetracking/farming/FarmingTracker.java | 20 +- .../farming/CompostTrackerTest.java | 323 ++++++++++++++++++ 11 files changed, 758 insertions(+), 9 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostState.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostTracker.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/timetracking/farming/CompostTrackerTest.java diff --git a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java index 3f1261d1d3..27b3f9054b 100644 --- a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java +++ b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java @@ -773,6 +773,7 @@ public final class WidgetID static class LunarSpellBook { static final int LUNAR_HOME_TELEPORT = 101; + static final int FERTILE_SOIL = 126; } static class ArceuusSpellBook diff --git a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java index c85a5341c9..12866d2110 100644 --- a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java +++ b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java @@ -505,6 +505,7 @@ public enum WidgetInfo SPELL_ARCEUUS_HOME_TELEPORT(WidgetID.SPELLBOOK_GROUP_ID, WidgetID.ArceuusSpellBook.ARCEUUS_HOME_TELEPORT), SPELL_KOUREND_HOME_TELEPORT(WidgetID.SPELLBOOK_GROUP_ID, WidgetID.StandardSpellBook.KOUREND_HOME_TELEPORT), SPELL_CATHERBY_HOME_TELEPORT(WidgetID.SPELLBOOK_GROUP_ID, WidgetID.StandardSpellBook.CATHERBY_HOME_TELEPORT), + SPELL_LUNAR_FERTILE_SOIL(WidgetID.SPELLBOOK_GROUP_ID, WidgetID.LunarSpellBook.FERTILE_SOIL), PVP_WILDERNESS_SKULL_CONTAINER(WidgetID.PVP_GROUP_ID, WidgetID.Pvp.WILDERNESS_SKULL_CONTAINER), PVP_SKULL_CONTAINER(WidgetID.PVP_GROUP_ID, WidgetID.Pvp.SKULL_CONTAINER), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingConfig.java index 8b6e7b2d58..36493a0373 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingConfig.java @@ -43,6 +43,7 @@ public interface TimeTrackingConfig extends Config String PREFER_SOONEST = "preferSoonest"; String NOTIFY = "notify"; String BIRDHOUSE_NOTIFY = "birdHouseNotification"; + String COMPOST = "compost"; @ConfigItem( keyName = "timeFormatMode", diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingPlugin.java index ca6df40a4d..6378193a06 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeTrackingPlugin.java @@ -44,6 +44,7 @@ import net.runelite.api.widgets.Widget; import net.runelite.api.widgets.WidgetInfo; import net.runelite.api.widgets.WidgetModalMode; import net.runelite.client.config.ConfigManager; +import net.runelite.client.eventbus.EventBus; import net.runelite.client.eventbus.Subscribe; import net.runelite.client.events.ConfigChanged; import net.runelite.client.events.RuneScapeProfileChanged; @@ -54,6 +55,7 @@ import static net.runelite.client.plugins.timetracking.TimeTrackingConfig.PREFER import static net.runelite.client.plugins.timetracking.TimeTrackingConfig.STOPWATCHES; import static net.runelite.client.plugins.timetracking.TimeTrackingConfig.TIMERS; import net.runelite.client.plugins.timetracking.clocks.ClockManager; +import net.runelite.client.plugins.timetracking.farming.CompostTracker; import net.runelite.client.plugins.timetracking.farming.FarmingContractManager; import net.runelite.client.plugins.timetracking.farming.FarmingTracker; import net.runelite.client.plugins.timetracking.hunter.BirdHouseTracker; @@ -77,6 +79,12 @@ public class TimeTrackingPlugin extends Plugin @Inject private Client client; + @Inject + private EventBus eventBus; + + @Inject + private CompostTracker compostTracker; + @Inject private FarmingTracker farmingTracker; @@ -125,6 +133,8 @@ public class TimeTrackingPlugin extends Plugin birdHouseTracker.loadFromConfig(); farmingTracker.loadCompletionTimes(); + eventBus.register(compostTracker); + final BufferedImage icon = ImageUtil.loadImageResource(getClass(), "watch.png"); panel = injector.getInstance(TimeTrackingPanel.class); @@ -148,6 +158,8 @@ public class TimeTrackingPlugin extends Plugin lastTickLocation = null; lastTickPostLogin = false; + eventBus.unregister(compostTracker); + if (panelUpdateFuture != null) { panelUpdateFuture.cancel(true); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeablePanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeablePanel.java index 8903a0b715..dfdda1d750 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeablePanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/TimeablePanel.java @@ -29,8 +29,11 @@ import java.awt.BorderLayout; import java.awt.Color; import java.awt.Dimension; import java.awt.GridLayout; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; import javax.swing.ImageIcon; import javax.swing.JLabel; +import javax.swing.JLayeredPane; import javax.swing.JPanel; import javax.swing.JToggleButton; import javax.swing.border.EmptyBorder; @@ -48,9 +51,20 @@ public class TimeablePanel extends JPanel { private static final ImageIcon NOTIFY_ICON = new ImageIcon(ImageUtil.loadImageResource(TimeTrackingPlugin.class, "notify_icon.png")); private static final ImageIcon NOTIFY_SELECTED_ICON = new ImageIcon(ImageUtil.loadImageResource(TimeTrackingPlugin.class, "notify_selected_icon.png")); + private static final Rectangle OVERLAY_ICON_BOUNDS; + + static + { + int width = Constants.ITEM_SPRITE_WIDTH * 2 / 3; + int height = Constants.ITEM_SPRITE_HEIGHT * 2 / 3; + int x = Constants.ITEM_SPRITE_WIDTH - width; + int y = Constants.ITEM_SPRITE_HEIGHT - height; + OVERLAY_ICON_BOUNDS = new Rectangle(x, y, width, height); + } private final T timeable; private final JLabel icon = new JLabel(); + private final JLabel overlayIcon = new JLabel(); private final JLabel farmingContractIcon = new JLabel(); private final JToggleButton notifyButton = new JToggleButton(); private final JLabel estimate = new JLabel(); @@ -70,6 +84,7 @@ public class TimeablePanel extends JPanel topContainer.setBackground(ColorScheme.DARKER_GRAY_COLOR); icon.setMinimumSize(new Dimension(Constants.ITEM_SPRITE_WIDTH, Constants.ITEM_SPRITE_HEIGHT)); + overlayIcon.setMinimumSize(OVERLAY_ICON_BOUNDS.getSize()); farmingContractIcon.setMinimumSize(new Dimension(Constants.ITEM_SPRITE_WIDTH, Constants.ITEM_SPRITE_HEIGHT)); JPanel infoPanel = new JPanel(); @@ -105,8 +120,15 @@ public class TimeablePanel extends JPanel iconPanel.add(notifyPanel, BorderLayout.EAST); iconPanel.add(farmingContractIcon, BorderLayout.WEST); + JLayeredPane layeredIconPane = new JLayeredPane(); + layeredIconPane.setPreferredSize(new Dimension(Constants.ITEM_SPRITE_WIDTH, Constants.ITEM_SPRITE_HEIGHT)); + layeredIconPane.add(icon, Integer.valueOf(0)); + layeredIconPane.add(overlayIcon, Integer.valueOf(1)); + icon.setBounds(0, 0, Constants.ITEM_SPRITE_WIDTH, Constants.ITEM_SPRITE_HEIGHT); + overlayIcon.setBounds(OVERLAY_ICON_BOUNDS); + topContainer.add(iconPanel, BorderLayout.EAST); - topContainer.add(icon, BorderLayout.WEST); + topContainer.add(layeredIconPane, BorderLayout.WEST); topContainer.add(infoPanel, BorderLayout.CENTER); progress.setValue(0); @@ -115,4 +137,21 @@ public class TimeablePanel extends JPanel add(topContainer, BorderLayout.NORTH); add(progress, BorderLayout.SOUTH); } + + public void setOverlayIconImage(BufferedImage overlayImg) + { + if (overlayImg == null) + { + overlayIcon.setIcon(null); + return; + } + + if (OVERLAY_ICON_BOUNDS.width != overlayImg.getWidth() || OVERLAY_ICON_BOUNDS.height != overlayImg.getHeight()) + { + overlayImg = ImageUtil.resizeImage(overlayImg, OVERLAY_ICON_BOUNDS.width, OVERLAY_ICON_BOUNDS.height); + } + + overlayIcon.setIcon(new ImageIcon(overlayImg)); + } + } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostState.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostState.java new file mode 100644 index 0000000000..659e29a282 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostState.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 LlemonDuck + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.timetracking.farming; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.runelite.api.ItemID; + +@RequiredArgsConstructor +@Getter +public enum CompostState +{ + + COMPOST(ItemID.COMPOST), + SUPERCOMPOST(ItemID.SUPERCOMPOST), + ULTRACOMPOST(ItemID.ULTRACOMPOST), + ; + + private final int itemId; + +} \ No newline at end of file diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostTracker.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostTracker.java new file mode 100644 index 0000000000..b5066658f6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/CompostTracker.java @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2022 LlemonDuck + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.timetracking.farming; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSet; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.RequiredArgsConstructor; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.ItemID; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Tile; +import net.runelite.api.annotations.Varbit; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.MenuOptionClicked; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetInfo; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.plugins.timetracking.TimeTrackingConfig; + +@Singleton +@Slf4j +@RequiredArgsConstructor(onConstructor = @__(@Inject)) +public class CompostTracker +{ + + @Value + @VisibleForTesting + static class PendingCompost + { + Instant timeout; + WorldPoint patchLocation; + FarmingPatch farmingPatch; + } + + private static final Duration COMPOST_ACTION_TIMEOUT = Duration.ofSeconds(30); + + private static final Pattern COMPOST_USED_ON_PATCH = Pattern.compile( + "You treat the .+ with (?ultra|super|)compost\\."); + private static final Pattern FERTILE_SOIL_CAST = Pattern.compile( + "The .+ has been treated with (?ultra|super|)compost\\."); + private static final Pattern ALREADY_TREATED = Pattern.compile( + "This .+ has already been (treated|fertilised) with (?ultra|super|)compost(?: - the spell can't make it any more fertile)?\\."); + private static final Pattern INSPECT_PATCH = Pattern.compile( + "This is an? .+\\. The soil has been treated with (?ultra|super|)compost\\..*"); + + private static final ImmutableSet COMPOST_ITEMS = ImmutableSet.of( + ItemID.COMPOST, + ItemID.SUPERCOMPOST, + ItemID.ULTRACOMPOST, + ItemID.BOTTOMLESS_COMPOST_BUCKET_22997 + ); + + private final Client client; + private final FarmingWorld farmingWorld; + private final ConfigManager configManager; + + @VisibleForTesting + final Map pendingCompostActions = new HashMap<>(); + + private static String configKey(FarmingPatch fp) + { + return fp.configKey() + "." + TimeTrackingConfig.COMPOST; + } + + public void setCompostState(FarmingPatch fp, CompostState state) + { + log.debug("Storing compost state [{}] for patch [{}]", state, fp); + if (state == null) + { + configManager.unsetRSProfileConfiguration(TimeTrackingConfig.CONFIG_GROUP, configKey(fp)); + } + else + { + configManager.setRSProfileConfiguration(TimeTrackingConfig.CONFIG_GROUP, configKey(fp), state); + } + } + + public CompostState getCompostState(FarmingPatch fp) + { + return configManager.getRSProfileConfiguration( + TimeTrackingConfig.CONFIG_GROUP, + configKey(fp), + CompostState.class + ); + } + + @Subscribe + public void onMenuOptionClicked(MenuOptionClicked e) + { + if (!isCompostAction(e)) + { + return; + } + + ObjectComposition patchDef = client.getObjectDefinition(e.getId()); + WorldPoint actionLocation = WorldPoint.fromScene(client, e.getParam0(), e.getParam1(), client.getPlane()); + FarmingPatch targetPatch = farmingWorld.getRegionsForLocation(actionLocation) + .stream() + .flatMap(fr -> Arrays.stream(fr.getPatches())) + .filter(fp -> fp.getVarbit() == patchDef.getVarbitId()) + .findFirst() + .orElse(null); + if (targetPatch == null) + { + return; + } + + log.debug("Storing pending compost action for patch [{}]", targetPatch); + PendingCompost pc = new PendingCompost( + Instant.now().plus(COMPOST_ACTION_TIMEOUT), + actionLocation, + targetPatch + ); + pendingCompostActions.put(targetPatch, pc); + } + + private boolean isCompostAction(MenuOptionClicked e) + { + switch (e.getMenuAction()) + { + case WIDGET_TARGET_ON_GAME_OBJECT: + Widget w = client.getSelectedWidget(); + assert w != null; + return COMPOST_ITEMS.contains(w.getItemId()) || w.getId() == WidgetInfo.SPELL_LUNAR_FERTILE_SOIL.getPackedId(); + + case GAME_OBJECT_FIRST_OPTION: + case GAME_OBJECT_SECOND_OPTION: + case GAME_OBJECT_THIRD_OPTION: + case GAME_OBJECT_FOURTH_OPTION: + case GAME_OBJECT_FIFTH_OPTION: + return "Inspect".equals(e.getMenuOption()); + + default: + return false; + } + } + + @Subscribe + public void onChatMessage(ChatMessage e) + { + if (e.getType() != ChatMessageType.GAMEMESSAGE && e.getType() != ChatMessageType.SPAM) + { + return; + } + + CompostState compostUsed = determineCompostUsed(e.getMessage()); + if (compostUsed == null) + { + return; + } + + this.expirePendingActions(); + + pendingCompostActions.values() + .stream() + .filter(this::playerIsBesidePatch) + .findFirst() + .ifPresent(pc -> + { + setCompostState(pc.getFarmingPatch(), compostUsed); + pendingCompostActions.remove(pc.getFarmingPatch()); + }); + } + + @Subscribe + public void onGameStateChanged(GameStateChanged e) + { + switch (e.getGameState()) + { + case LOGGED_IN: + case LOADING: + return; + + default: + pendingCompostActions.clear(); + } + } + + private boolean playerIsBesidePatch(PendingCompost pendingCompost) + { + // find gameobject instance in scene + // it is possible that the scene has reloaded between use and action occurring so we use worldpoint + // instead of storing scene coords in the menuoptionclicked event + LocalPoint localPatchLocation = LocalPoint.fromWorld(client, pendingCompost.getPatchLocation()); + if (localPatchLocation == null) + { + return false; + } + + @Varbit int patchVarb = pendingCompost.getFarmingPatch().getVarbit(); + Tile patchTile = client.getScene() + .getTiles()[client.getPlane()][localPatchLocation.getSceneX()][localPatchLocation.getSceneY()]; + GameObject patchObject = null; + for (GameObject go : patchTile.getGameObjects()) + { + if (go != null && client.getObjectDefinition(go.getId()).getVarbitId() == patchVarb) + { + patchObject = go; + break; + } + } + assert patchObject != null; + + // player coords + final WorldPoint playerPos = client.getLocalPlayer().getWorldLocation(); + final int playerX = playerPos.getX(); + final int playerY = playerPos.getY(); + + // patch coords + final WorldPoint patchBase = pendingCompost.getPatchLocation(); + final int minX = patchBase.getX(); + final int minY = patchBase.getY(); + final int maxX = minX + patchObject.sizeX() - 1; + final int maxY = minY + patchObject.sizeY() - 1; + + // player should be within one tile of these coords + return playerX >= (minX - 1) && playerX <= (maxX + 1) && playerY >= (minY - 1) && playerY <= (maxY + 1); + } + + private void expirePendingActions() + { + pendingCompostActions.values().removeIf(e -> Instant.now().isAfter(e.getTimeout())); + } + + @VisibleForTesting + static CompostState determineCompostUsed(String chatMessage) + { + if (!chatMessage.contains("compost")) + { + return null; + } + + Matcher matcher; + if ((matcher = COMPOST_USED_ON_PATCH.matcher(chatMessage)).matches() || + (matcher = FERTILE_SOIL_CAST.matcher(chatMessage)).matches() || + (matcher = ALREADY_TREATED.matcher(chatMessage)).matches() || + (matcher = INSPECT_PATCH.matcher(chatMessage)).matches()) + { + String compostGroup = matcher.group("compostType"); + switch (compostGroup) + { + case "ultra": + return CompostState.ULTRACOMPOST; + case "super": + return CompostState.SUPERCOMPOST; + default: + return CompostState.COMPOST; + } + } + + return null; + } + +} \ No newline at end of file diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingPatch.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingPatch.java index 835f17e463..1599393ec8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingPatch.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingPatch.java @@ -28,6 +28,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; +import lombok.ToString; import net.runelite.api.annotations.Varbit; import net.runelite.client.plugins.timetracking.TimeTrackingConfig; @@ -35,13 +36,17 @@ import net.runelite.client.plugins.timetracking.TimeTrackingConfig; access = AccessLevel.PACKAGE ) @Getter +@ToString(onlyExplicitlyIncluded = true) class FarmingPatch { @Setter(AccessLevel.PACKAGE) + @ToString.Include private FarmingRegion region; + @ToString.Include private final String name; @Getter(onMethod_ = {@Varbit}) private final int varbit; + @ToString.Include private final PatchImplementation implementation; String configKey() diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTabPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTabPanel.java index 1d16cbeae5..cc30311eef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTabPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTabPanel.java @@ -43,10 +43,12 @@ import net.runelite.client.plugins.timetracking.TimeTrackingConfig; import net.runelite.client.plugins.timetracking.TimeablePanel; import net.runelite.client.ui.ColorScheme; import net.runelite.client.ui.FontManager; +import net.runelite.client.util.AsyncBufferedImage; public class FarmingTabPanel extends TabContentPanel { private final FarmingTracker farmingTracker; + private final CompostTracker compostTracker; private final ItemManager itemManager; private final ConfigManager configManager; private final TimeTrackingConfig config; @@ -55,6 +57,7 @@ public class FarmingTabPanel extends TabContentPanel FarmingTabPanel( FarmingTracker farmingTracker, + CompostTracker compostTracker, ItemManager itemManager, ConfigManager configManager, TimeTrackingConfig config, @@ -63,6 +66,7 @@ public class FarmingTabPanel extends TabContentPanel ) { this.farmingTracker = farmingTracker; + this.compostTracker = compostTracker; this.itemManager = itemManager; this.configManager = configManager; this.config = config; @@ -131,7 +135,6 @@ public class FarmingTabPanel extends TabContentPanel p.setBorder(null); } } - } @Override @@ -150,10 +153,26 @@ public class FarmingTabPanel extends TabContentPanel FarmingPatch patch = panel.getTimeable(); PatchPrediction prediction = farmingTracker.predictPatch(patch); + CompostState compostState = compostTracker.getCompostState(patch); + String compostTooltip = ""; + if (compostState != null) + { + AsyncBufferedImage compostImg = itemManager.getImage(compostState.getItemId()); + Runnable compostOverlayRunnable = () -> panel.setOverlayIconImage(compostImg); + compostImg.onLoaded(compostOverlayRunnable); + compostOverlayRunnable.run(); + + compostTooltip = " with " + compostState.name().toLowerCase(); + } + else + { + panel.setOverlayIconImage(null); + } + if (prediction == null) { itemManager.getImage(Produce.WEEDS.getItemID()).addTo(panel.getIcon()); - panel.getIcon().setToolTipText("Unknown state"); + panel.getIcon().setToolTipText("Unknown state" + compostTooltip); panel.getProgress().setMaximumValue(0); panel.getProgress().setValue(0); panel.getProgress().setVisible(false); @@ -165,12 +184,12 @@ public class FarmingTabPanel extends TabContentPanel if (prediction.getProduce().getItemID() < 0) { panel.getIcon().setIcon(null); - panel.getIcon().setToolTipText("Unknown state"); + panel.getIcon().setToolTipText("Unknown state" + compostTooltip); } else { itemManager.getImage(prediction.getProduce().getItemID()).addTo(panel.getIcon()); - panel.getIcon().setToolTipText(prediction.getProduce().getName()); + panel.getIcon().setToolTipText(prediction.getProduce().getName() + compostTooltip); } switch (prediction.getCropState()) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTracker.java b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTracker.java index fe314c46fa..ad4df62e37 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTracker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/timetracking/farming/FarmingTracker.java @@ -63,6 +63,7 @@ public class FarmingTracker private final TimeTrackingConfig config; private final FarmingWorld farmingWorld; private final Notifier notifier; + private final CompostTracker compostTracker; private final Map summaries = new EnumMap<>(Tab.class); @@ -78,7 +79,7 @@ public class FarmingTracker private boolean firstNotifyCheck = true; @Inject - private FarmingTracker(Client client, ItemManager itemManager, ConfigManager configManager, TimeTrackingConfig config, FarmingWorld farmingWorld, Notifier notifier) + private FarmingTracker(Client client, ItemManager itemManager, ConfigManager configManager, TimeTrackingConfig config, FarmingWorld farmingWorld, Notifier notifier, CompostTracker compostTracker) { this.client = client; this.itemManager = itemManager; @@ -86,11 +87,12 @@ public class FarmingTracker this.config = config; this.farmingWorld = farmingWorld; this.notifier = notifier; + this.compostTracker = compostTracker; } public FarmingTabPanel createTabPanel(Tab tab, FarmingContractManager farmingContractManager) { - return new FarmingTabPanel(this, itemManager, configManager, config, farmingWorld.getTabs().get(tab), farmingContractManager); + return new FarmingTabPanel(this, compostTracker, itemManager, configManager, config, farmingWorld.getTabs().get(tab), farmingContractManager); } /** @@ -148,6 +150,12 @@ public class FarmingTracker String strVarbit = Integer.toString(client.getVarbitValue(varbit)); String storedValue = configManager.getRSProfileConfiguration(TimeTrackingConfig.CONFIG_GROUP, key); + PatchState currentPatchState = patch.getImplementation().forVarbitValue(client.getVarbitValue(varbit)); + if (currentPatchState == null) + { + continue; + } + if (storedValue != null) { String[] parts = storedValue.split(":"); @@ -172,9 +180,8 @@ public class FarmingTracker else if (!newRegionLoaded && timeSinceModalClose > 1) { PatchState previousPatchState = patch.getImplementation().forVarbitValue(Integer.parseInt(parts[0])); - PatchState currentPatchState = patch.getImplementation().forVarbitValue(client.getVarbitValue(varbit)); - if (previousPatchState == null || currentPatchState == null) + if (previousPatchState == null) { continue; } @@ -217,6 +224,11 @@ public class FarmingTracker } } + if (currentPatchState.getCropState() == CropState.DEAD || currentPatchState.getCropState() == CropState.HARVESTABLE) + { + compostTracker.setCompostState(patch, null); + } + String value = strVarbit + ":" + unixNow; configManager.setRSProfileConfiguration(TimeTrackingConfig.CONFIG_GROUP, key, value); changed = true; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/timetracking/farming/CompostTrackerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/timetracking/farming/CompostTrackerTest.java new file mode 100644 index 0000000000..42dba61b5d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/timetracking/farming/CompostTrackerTest.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2022 LlemonDuck + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.timetracking.farming; + +import com.google.inject.Guice; +import com.google.inject.testing.fieldbinder.Bind; +import com.google.inject.testing.fieldbinder.BoundFieldModule; +import java.time.Instant; +import java.util.Collections; +import javax.inject.Inject; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.ItemID; +import net.runelite.api.MenuAction; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Player; +import net.runelite.api.Scene; +import net.runelite.api.Tile; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.MenuOptionClicked; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetInfo; +import net.runelite.client.config.ConfigManager; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ErrorCollector; +import org.junit.runner.RunWith; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.Mock; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class CompostTrackerTest +{ + + @Inject + private CompostTracker compostTracker; + + @Mock + @Bind + private Client client; + + @Mock + @Bind + private FarmingWorld farmingWorld; + + @Mock + @Bind + private ConfigManager configManager; + + @Mock + @Bind + private FarmingRegion farmingRegion; + + @Mock + @Bind + private FarmingPatch farmingPatch; + + @Mock + @Bind + private GameObject patchObject; + + @Mock + @Bind + private Player player; + + @Mock + @Bind + private Scene scene; + + @Mock + @Bind + private Tile tile; + + @Mock + @Bind + private ObjectComposition patchDef; + + @Rule + public ErrorCollector collector = new ErrorCollector(); + + private static final int PATCH_ID = 12345; + private static final int PATCH_VARBIT = 54321; + private static final WorldPoint worldPoint = new WorldPoint(1, 2, 0); + + @Before + public void before() + { + Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this); + compostTracker.pendingCompostActions.clear(); + + when(client.getBaseX()).thenReturn(0); + when(client.getBaseY()).thenReturn(0); + when(client.getPlane()).thenReturn(0); + when(client.getLocalPlayer()).thenReturn(player); + when(player.getWorldLocation()).thenReturn(worldPoint); + when(client.getScene()).thenReturn(scene); + when(client.getObjectDefinition(PATCH_ID)).thenReturn(patchDef); + + when(scene.getTiles()).thenReturn(new Tile[][][]{{null, {null, null, tile}}}); // indices match worldPoint + when(tile.getGameObjects()).thenReturn(new GameObject[]{patchObject}); + + when(farmingWorld.getRegionsForLocation(any())).thenReturn(Collections.singleton(farmingRegion)); + + when(farmingRegion.getPatches()).thenReturn(new FarmingPatch[]{farmingPatch}); + + when(farmingPatch.getVarbit()).thenReturn(PATCH_VARBIT); + when(farmingPatch.configKey()).thenReturn("MOCK"); + + when(patchObject.getId()).thenReturn(PATCH_ID); + when(patchObject.sizeX()).thenReturn(1); + when(patchObject.sizeY()).thenReturn(1); + + when(patchDef.getVarbitId()).thenReturn(PATCH_VARBIT); + } + + @Test + public void setCompostState_storesNonNullChangesToConfig() + { + compostTracker.setCompostState(farmingPatch, CompostState.COMPOST); + verify(configManager).setRSProfileConfiguration("timetracking", "MOCK.compost", CompostState.COMPOST); + } + + @Test + public void setCompostState_storesNullChangesByClearingConfig() + { + compostTracker.setCompostState(farmingPatch, null); + verify(configManager).unsetRSProfileConfiguration("timetracking", "MOCK.compost"); + } + + @Test + public void getCompostState_directlyReturnsFromConfig() + { + when(configManager.getRSProfileConfiguration("timetracking", "MOCK.compost", CompostState.class)).thenReturn( + CompostState.SUPERCOMPOST); + assertThat(compostTracker.getCompostState(farmingPatch), is(CompostState.SUPERCOMPOST)); + } + + @Test + public void determineCompostUsed_returnsAppropriateCompostValues() + { + // invalid + collector.checkThat( + CompostTracker.determineCompostUsed("This is not a farming chat message."), + is((CompostState) null) + ); + collector.checkThat( + CompostTracker.determineCompostUsed("Contains word compost but is not examine message."), + is((CompostState) null) + ); + + // inspect + collector.checkThat( + CompostTracker.determineCompostUsed("This is an allotment. The soil has been treated with supercompost. The patch is empty and weeded."), + is(CompostState.SUPERCOMPOST) + ); + + // fertile soil on existing patch + collector.checkThat( + CompostTracker.determineCompostUsed("This patch has already been fertilised with ultracompost - the spell can't make it any more fertile."), + is(CompostState.ULTRACOMPOST) + ); + // fertile soil on cleared patch + collector.checkThat( + CompostTracker.determineCompostUsed("The herb patch has been treated with supercompost."), + is(CompostState.SUPERCOMPOST) + ); + + // bucket on cleared patch + collector.checkThat( + CompostTracker.determineCompostUsed("You treat the herb patch with ultracompost."), + is(CompostState.ULTRACOMPOST) + ); + collector.checkThat( + CompostTracker.determineCompostUsed("You treat the tree patch with compost."), + is(CompostState.COMPOST) + ); + collector.checkThat( + CompostTracker.determineCompostUsed("You treat the fruit tree patch with supercompost."), + is(CompostState.SUPERCOMPOST) + ); + } + + @Test + public void onMenuOptionClicked_queuesPendingCompostForInspectActions() + { + MenuOptionClicked inspectPatchAction = mock(MenuOptionClicked.class); + when(inspectPatchAction.getMenuAction()).thenReturn(MenuAction.GAME_OBJECT_SECOND_OPTION); + when(inspectPatchAction.getMenuOption()).thenReturn("Inspect"); + when(inspectPatchAction.getId()).thenReturn(PATCH_ID); + when(inspectPatchAction.getParam0()).thenReturn(1); + when(inspectPatchAction.getParam1()).thenReturn(2); + + compostTracker.onMenuOptionClicked(inspectPatchAction); + CompostTracker.PendingCompost actual = compostTracker.pendingCompostActions.get(farmingPatch); + + assertThat(actual.getFarmingPatch(), is(farmingPatch)); + assertThat(actual.getPatchLocation(), is(new WorldPoint(1, 2, 0))); + } + + @Test + public void onMenuOptionClicked_queuesPendingCompostForCompostActions() + { + Widget widget = mock(Widget.class); + when(client.getSelectedWidget()).thenReturn(widget); + when(widget.getItemId()).thenReturn(ItemID.ULTRACOMPOST); + + MenuOptionClicked inspectPatchAction = mock(MenuOptionClicked.class); + when(inspectPatchAction.getMenuAction()).thenReturn(MenuAction.WIDGET_TARGET_ON_GAME_OBJECT); + when(inspectPatchAction.getId()).thenReturn(PATCH_ID); + when(inspectPatchAction.getParam0()).thenReturn(1); + when(inspectPatchAction.getParam1()).thenReturn(2); + + compostTracker.onMenuOptionClicked(inspectPatchAction); + CompostTracker.PendingCompost actual = compostTracker.pendingCompostActions.get(farmingPatch); + + assertThat(actual.getFarmingPatch(), is(farmingPatch)); + assertThat(actual.getPatchLocation(), is(new WorldPoint(1, 2, 0))); + } + + @Test + public void onMenuOptionClicked_queuesPendingCompostForFertileSoilSpellActions() + { + Widget widget = mock(Widget.class); + when(client.getSelectedWidget()).thenReturn(widget); + when(widget.getId()).thenReturn(WidgetInfo.SPELL_LUNAR_FERTILE_SOIL.getPackedId()); + + MenuOptionClicked inspectPatchAction = mock(MenuOptionClicked.class); + when(inspectPatchAction.getMenuAction()).thenReturn(MenuAction.WIDGET_TARGET_ON_GAME_OBJECT); + when(inspectPatchAction.getId()).thenReturn(PATCH_ID); + when(inspectPatchAction.getParam0()).thenReturn(1); + when(inspectPatchAction.getParam1()).thenReturn(2); + + compostTracker.onMenuOptionClicked(inspectPatchAction); + CompostTracker.PendingCompost actual = compostTracker.pendingCompostActions.get(farmingPatch); + + assertThat(actual.getFarmingPatch(), is(farmingPatch)); + assertThat(actual.getPatchLocation(), is(new WorldPoint(1, 2, 0))); + } + + @Test + public void onChatMessage_ignoresInvalidTypes() + { + ChatMessage chatEvent = mock(ChatMessage.class); + when(chatEvent.getType()).thenReturn(ChatMessageType.PUBLICCHAT); + + compostTracker.onChatMessage(chatEvent); + + verifyNoInteractions(client); + verifyNoInteractions(farmingWorld); + } + + @Test + public void onChatMessage_handlesInspectMessages() + { + ChatMessage chatEvent = mock(ChatMessage.class); + when(chatEvent.getType()).thenReturn(ChatMessageType.SPAM); + when(chatEvent.getMessage()).thenReturn("This is a tree patch. The soil has been treated with ultracompost. The patch is empty and weeded."); + + compostTracker.pendingCompostActions.put(farmingPatch, new CompostTracker.PendingCompost(Instant.MAX, worldPoint, farmingPatch)); + compostTracker.onChatMessage(chatEvent); + + verify(configManager).setRSProfileConfiguration("timetracking", "MOCK.compost", CompostState.ULTRACOMPOST); + } + + @Test + public void onChatMessage_handlesBucketUseMessages() + { + ChatMessage chatEvent = mock(ChatMessage.class); + when(chatEvent.getType()).thenReturn(ChatMessageType.SPAM); + when(chatEvent.getMessage()).thenReturn("You treat the herb patch with compost."); + + compostTracker.pendingCompostActions.put(farmingPatch, new CompostTracker.PendingCompost(Instant.MAX, worldPoint, farmingPatch)); + compostTracker.onChatMessage(chatEvent); + + verify(configManager).setRSProfileConfiguration("timetracking", "MOCK.compost", CompostState.COMPOST); + } + + @Test + public void onChatMessage_handlesFertileSoilMessages() + { + ChatMessage chatEvent = mock(ChatMessage.class); + when(chatEvent.getType()).thenReturn(ChatMessageType.SPAM); + when(chatEvent.getMessage()).thenReturn("The allotment has been treated with supercompost."); + + compostTracker.pendingCompostActions.put(farmingPatch, new CompostTracker.PendingCompost(Instant.MAX, worldPoint, farmingPatch)); + compostTracker.onChatMessage(chatEvent); + + verify(configManager).setRSProfileConfiguration("timetracking", "MOCK.compost", CompostState.SUPERCOMPOST); + } + +}