Repository: pajlads/DinkPlugin
Pull request #108 (2022-12-31 05:32:09)

refactor: split up Utils
Split up `Utils` into `ItemUtils`, `QuestUtils`, `TimeUtils`, `WorldUtils` for cleanliness


https://www.javadoc.io/doc/org.projectlombok/lombok/latest/lombok/experimental/UtilityClass.html


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c22d05..c9e9db6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
 - Minor: Add warning logs for improper runescape settings that impact notifiers. (#92)
 - Bugfix: Reduce timeouts from uploading screenshots on slow connections. (#96)
 - Bugfix: Ensure boss name is included in boss slayer messages. (#88)
+- Dev: Clean up utility code package. (#108)
 - Dev: Bump mockito version to 4.11.0. (#107)
 - Dev: Add more plugin-hub search tags (#101)
 - Dev: Simplify notifier send image evaluation. (#102)
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index d43988e..62fc5c0 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -13,6 +13,7 @@ import dinkplugin.notifiers.PetNotifier;
 import dinkplugin.notifiers.QuestNotifier;
 import dinkplugin.notifiers.SlayerNotifier;
 import dinkplugin.notifiers.SpeedrunNotifier;
+import dinkplugin.util.Utils;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.ChatMessageType;
 import net.runelite.api.events.ActorDeath;
diff --git a/src/main/java/dinkplugin/SettingsValidator.java b/src/main/java/dinkplugin/SettingsValidator.java
index 9d947fb..9b9c7f5 100644
--- a/src/main/java/dinkplugin/SettingsValidator.java
+++ b/src/main/java/dinkplugin/SettingsValidator.java
@@ -2,6 +2,7 @@ package dinkplugin;
 
 import dinkplugin.notifiers.CollectionNotifier;
 import dinkplugin.notifiers.CombatTaskNotifier;
+import dinkplugin.util.Utils;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
diff --git a/src/main/java/dinkplugin/Utils.java b/src/main/java/dinkplugin/Utils.java
deleted file mode 100644
index 72554e7..0000000
--- a/src/main/java/dinkplugin/Utils.java
+++ /dev/null
@@ -1,306 +0,0 @@
-package dinkplugin;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import dinkplugin.message.NotificationBody;
-import dinkplugin.notifiers.data.SerializedItemStack;
-import net.runelite.api.Client;
-import net.runelite.api.InventoryID;
-import net.runelite.api.Item;
-import net.runelite.api.ItemComposition;
-import net.runelite.api.ItemContainer;
-import net.runelite.api.WorldType;
-import net.runelite.api.annotations.Varbit;
-import net.runelite.api.widgets.Widget;
-import net.runelite.api.widgets.WidgetInfo;
-import net.runelite.client.game.ItemManager;
-import net.runelite.client.game.ItemStack;
-import net.runelite.client.util.ColorUtil;
-import okhttp3.MediaType;
-import okhttp3.MultipartBody;
-import org.apache.commons.lang3.ObjectUtils;
-import org.apache.commons.lang3.StringUtils;
-import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
-import org.jetbrains.annotations.VisibleForTesting;
-
-import javax.imageio.ImageIO;
-import java.awt.Color;
-import java.awt.image.BufferedImage;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.time.Duration;
-import java.time.LocalTime;
-import java.time.temporal.Temporal;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.function.BinaryOperator;
-import java.util.function.Function;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-import static java.time.temporal.ChronoField.HOUR_OF_DAY;
-import static java.time.temporal.ChronoField.MILLI_OF_SECOND;
-import static java.time.temporal.ChronoField.MINUTE_OF_HOUR;
-import static java.time.temporal.ChronoField.SECOND_OF_MINUTE;
-import static net.runelite.api.ItemID.*;
-
-public class Utils {
-
-    public static final Color PINK = ColorUtil.fromHex("#f40098"); // analogous to RED in CIELCh_uv color space
-    public static final Color RED = ColorUtil.fromHex("#ca2a2d"); // red used in pajaW
-
-    private static final Pattern TIME_PATTERN = Pattern.compile("\\b(?:(?<hours>\\d+):)?(?<minutes>\\d+):(?<seconds>\\d{2})(?:\\.(?<fractional>\\d{2}))?\\b");
-
-    private static final Set<WorldType> IGNORED_WORLDS = EnumSet.of(WorldType.PVP_ARENA, WorldType.QUEST_SPEEDRUNNING, WorldType.NOSAVE_MODE, WorldType.TOURNAMENT_WORLD);
-
-    private static final Set<Integer> LMS_REGIONS = ImmutableSet.of(13658, 13659, 13660, 13914, 13915, 13916, 13918, 13919, 13920, 14174, 14175, 14176, 14430, 14431, 14432);
-    private static final Set<Integer> POH_REGIONS = ImmutableSet.of(7257, 7513, 7514, 7769, 7770, 8025, 8026);
-    private static final Set<Integer> SOUL_REGIONS = ImmutableSet.of(8493, 8749, 9005);
-    private static final int CASTLE_WARS_REGION = 9520;
-
-    private static final BinaryOperator<Item> SUM_ITEM_QUANTITIES = (a, b) -> new Item(a.getId(), a.getQuantity() + b.getQuantity());
-    private static final BinaryOperator<ItemStack> SUM_ITEM_STACK_QUANTITIES = (a, b) -> new ItemStack(a.getId(), a.getQuantity() + b.getQuantity(), a.getLocation());
-
-    @VisibleForTesting
-    public static final int CASTLE_WARS_COUNTDOWN = 380;
-    @Varbit
-    private static final int CASTLE_WARS_X_OFFSET = 156;
-    @Varbit
-    private static final int CASTLE_WARS_Y_OFFSET = 157;
-
-    @VisibleForTesting
-    public static final @Varbit int ENABLE_PRECISE_TIMING = 11866;
-
-    private static final Set<Integer> NEVER_KEPT_ITEMS = ImmutableSet.of(
-        CLUE_BOX, LOOTING_BAG,
-        AMULET_OF_THE_DAMNED, AMULET_OF_THE_DAMNED_FULL,
-        BRACELET_OF_ETHEREUM, BRACELET_OF_ETHEREUM_UNCHARGED,
-        AVAS_ACCUMULATOR, AVAS_ATTRACTOR, MAGIC_SECATEURS,
-        SILLY_JESTER_HAT, SILLY_JESTER_TOP, SILLY_JESTER_TIGHTS, SILLY_JESTER_BOOTS,
-        LUNAR_HELM, LUNAR_TORSO, LUNAR_LEGS, LUNAR_GLOVES, LUNAR_BOOTS,
-        LUNAR_CAPE, LUNAR_AMULET, LUNAR_RING, LUNAR_STAFF,
-        SHATTERED_RELICS_ADAMANT_TROPHY, SHATTERED_RELICS_BRONZE_TROPHY, SHATTERED_RELICS_DRAGON_TROPHY,
-        SHATTERED_RELICS_IRON_TROPHY, SHATTERED_RELICS_MITHRIL_TROPHY, SHATTERED_RELICS_RUNE_TROPHY, SHATTERED_RELICS_STEEL_TROPHY,
-        TRAILBLAZER_ADAMANT_TROPHY, TRAILBLAZER_BRONZE_TROPHY, TRAILBLAZER_DRAGON_TROPHY, TRAILBLAZER_IRON_TROPHY,
-        TRAILBLAZER_MITHRIL_TROPHY, TRAILBLAZER_RUNE_TROPHY, TRAILBLAZER_STEEL_TROPHY,
-        TWISTED_ADAMANT_TROPHY, TWISTED_BRONZE_TROPHY, TWISTED_DRAGON_TROPHY, TWISTED_IRON_TROPHY,
-        TWISTED_MITHRIL_TROPHY, TWISTED_RUNE_TROPHY, TWISTED_STEEL_TROPHY
-    );
-
-    public static boolean isItemNeverKeptOnDeath(int itemId) {
-        // https://oldschool.runescape.wiki/w/Items_Kept_on_Death#Items_that_are_never_kept
-        // https://oldschoolrunescape.fandom.com/wiki/Items_Kept_on_Death#Items_that_are_never_kept
-        return NEVER_KEPT_ITEMS.contains(itemId);
-    }
-
-    public static long getPrice(ItemManager itemManager, int itemId) {
-        int price = itemManager.getItemPrice(itemId);
-        return price > 0 ? price : itemManager.getItemComposition(itemId).getPrice();
-    }
-
-    public static boolean isIgnoredWorld(Set<WorldType> worldType) {
-        return !Collections.disjoint(IGNORED_WORLDS, worldType);
-    }
-
-    public static boolean isPvpWorld(Set<WorldType> worldType) {
-        return worldType.contains(WorldType.PVP) || worldType.contains(WorldType.DEADMAN);
-    }
-
-    public static boolean isPvpSafeZone(Client client) {
-        Widget widget = client.getWidget(WidgetInfo.PVP_WORLD_SAFE_ZONE);
-        return widget != null && !widget.isSelfHidden();
-    }
-
-    public static boolean isCastleWars(Client client) {
-        return client.getLocalPlayer().getWorldLocation().getRegionID() == CASTLE_WARS_REGION &&
-            (client.getVarpValue(CASTLE_WARS_COUNTDOWN) > 0 || client.getVarbitValue(CASTLE_WARS_X_OFFSET) > 0 || client.getVarbitValue(CASTLE_WARS_Y_OFFSET) > 0);
-    }
-
-    public static boolean isLastManStanding(Client client) {
-        return LMS_REGIONS.contains(client.getLocalPlayer().getWorldLocation().getRegionID());
-    }
-
-    public static boolean isPestControl(Client client) {
-        Widget widget = client.getWidget(WidgetInfo.PEST_CONTROL_BLUE_SHIELD);
-        return widget != null;
-    }
-
-    public static boolean isPlayerOwnedHouse(Client client) {
-        return POH_REGIONS.contains(client.getLocalPlayer().getWorldLocation().getRegionID());
-    }
-
-    public static boolean isSafeArea(Client client) {
-        return isCastleWars(client) || isPestControl(client) || isPlayerOwnedHouse(client);
-    }
-
-    public static boolean isSoulWars(Client client) {
-        return SOUL_REGIONS.contains(client.getLocalPlayer().getWorldLocation().getRegionID());
-    }
-
-    public static boolean isSettingsOpen(@NotNull Client client) {
-        Widget widget = client.getWidget(WidgetInfo.SETTINGS_INIT);
-        return widget != null && !widget.isSelfHidden();
-    }
-
-    public static String getPlayerName(Client client) {
-        return client.getLocalPlayer().getName();
-    }
-
-    public static byte[] convertImageToByteArray(BufferedImage bufferedImage) throws IOException {
-        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
-        ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
-        return byteArrayOutputStream.toByteArray();
-    }
-
-    public static Collection<Item> getItems(Client client) {
-        return Stream.of(InventoryID.INVENTORY, InventoryID.EQUIPMENT)
-            .map(client::getItemContainer)
-            .filter(Objects::nonNull)
-            .map(ItemContainer::getItems)
-            .flatMap(Arrays::stream)
-            .filter(Objects::nonNull)
-            .filter(item -> item.getId() >= 0) // -1 implies empty slot
-            .collect(Collectors.toList());
-    }
-
-    public static <K, V> Map<K, V> reduce(@NotNull Iterable<V> items, Function<V, K> deriveKey, BinaryOperator<V> aggregate) {
-        final Map<K, V> map = new LinkedHashMap<>();
-        items.forEach(v -> map.merge(deriveKey.apply(v), v, aggregate));
-        return map;
-    }
-
-    public static Map<Integer, Item> reduceItems(@NotNull Iterable<Item> items) {
-        return reduce(items, Item::getId, SUM_ITEM_QUANTITIES);
-    }
-
-    @NotNull
-    public static Collection<ItemStack> reduceItemStack(@NotNull Iterable<ItemStack> items) {
-        return reduce(items, ItemStack::getId, SUM_ITEM_STACK_QUANTITIES).values();
-    }
-
-    public static SerializedItemStack stackFromItem(ItemManager itemManager, Item item) {
-        int id = item.getId();
-        int quantity = item.getQuantity();
-        int price = itemManager.getItemPrice(id);
-        ItemComposition composition = itemManager.getItemComposition(id);
-        return new SerializedItemStack(id, quantity, price, composition.getName());
-    }
-
-    public static String getItemImageUrl(int itemId) {
-        return "https://static.runelite.net/cache/item/icon/" + itemId + ".png";
-    }
-
-    public static String getNpcImageUrl(int npcId) {
-        return String.format("https://chisel.weirdgloop.org/static/img/osrs-npc/%d_128.png", npcId);
-    }
-
-    public static List<NotificationBody.Embed> buildEmbeds(int[] itemIds) {
-        return Arrays.stream(itemIds)
-            .mapToObj(Utils::getItemImageUrl)
-            .map(NotificationBody.UrlEmbed::new)
-            .map(NotificationBody.Embed::new)
-            .collect(Collectors.toList());
-    }
-
-    public static boolean hasImage(@NotNull MultipartBody body) {
-        return body.parts().stream().anyMatch(part -> {
-            MediaType type = part.body().contentType();
-            return type != null && "image".equals(type.type());
-        });
-    }
-
-    public static boolean isPreciseTiming(Client client) {
-        return client.getVarbitValue(ENABLE_PRECISE_TIMING) > 0;
-    }
-
-    @NotNull
-    public static Duration parseTime(String in) {
-        Matcher m = TIME_PATTERN.matcher(in);
-        if (!m.find()) return Duration.ZERO;
-
-        int minutes = Integer.parseInt(m.group("minutes"));
-        int seconds = Integer.parseInt(m.group("seconds"));
-
-        Duration d = Duration.ofMinutes(minutes).plusSeconds(seconds);
-
-        String hours = m.group("hours");
-        if (hours != null) {
-            d = d.plusHours(Integer.parseInt(hours));
-        }
-
-        String fractional = m.group("fractional");
-        if (fractional != null) {
-            // osrs sends 2 digits, but this is robust to changes
-            String f = fractional.length() < 3 ? StringUtils.rightPad(fractional, 3, '0') : fractional.substring(0, 3);
-            d = d.plusMillis(Integer.parseInt(f));
-        }
-
-        return d;
-    }
-
-    @NotNull
-    public static String format(@Nullable Duration duration, boolean precise) {
-        Temporal time = ObjectUtils.defaultIfNull(duration, Duration.ZERO).addTo(LocalTime.of(0, 0));
-        StringBuilder sb = new StringBuilder();
-
-        int h = time.get(HOUR_OF_DAY);
-        if (h > 0)
-            sb.append(String.format("%02d", h)).append(':');
-
-        sb.append(String.format("%02d", time.get(MINUTE_OF_HOUR))).append(':');
-        sb.append(String.format("%02d", time.get(SECOND_OF_MINUTE)));
-
-        if (precise)
-            sb.append('.').append(String.format("%02d", time.get(MILLI_OF_SECOND) / 10));
-
-        return sb.toString();
-    }
-
-    // Credit to: https://github.com/oliverpatrick/Enhanced-Discord-Notifications/blob/master/src/main/java/com/enhanceddiscordnotifications/EnhancedDiscordNotificationsPlugin.java
-    // This method existed and seemed fairly solid.
-
-    private static final Pattern QUEST_PATTERN_1 = Pattern.compile(".+?ve\\.*? (?<verb>been|rebuilt|.+?ed)? ?(?:the )?'?(?<quest>.+?)'?(?: [Qq]uest)?[!.]?$");
-    private static final Pattern QUEST_PATTERN_2 = Pattern.compile("'?(?<quest>.+?)'?(?: [Qq]uest)? (?<verb>[a-z]\\w+?ed)?(?: f.*?)?[!.]?$");
-    private static final ImmutableList<String> RFD_TAGS = ImmutableList.of("Another Cook", "freed", "defeated", "saved");
-    private static final ImmutableList<String> WORD_QUEST_IN_NAME_TAGS = ImmutableList.of("Another Cook", "Doric", "Heroes", "Legends", "Observatory", "Olaf", "Waterfall");
-
-    public static String parseQuestWidget(final String text) {
-        // "You have completed The Corsair Curse!"
-        final Matcher questMatch1 = QUEST_PATTERN_1.matcher(text);
-        // "'One Small Favour' completed!"
-        final Matcher questMatch2 = QUEST_PATTERN_2.matcher(text);
-        final Matcher questMatchFinal = questMatch1.matches() ? questMatch1 : questMatch2;
-        if (!questMatchFinal.matches()) {
-            return "Unable to find quest name!";
-        }
-
-        String quest = questMatchFinal.group("quest");
-        String verb = questMatchFinal.group("verb") != null ? questMatchFinal.group("verb") : "";
-
-        if (verb.contains("kind of")) {
-            quest += " partial completion";
-        } else if (verb.contains("completely")) {
-            quest += " II";
-        }
-
-        if (RFD_TAGS.stream().anyMatch((quest + verb)::contains)) {
-            quest = "Recipe for Disaster - " + quest;
-        }
-
-        if (WORD_QUEST_IN_NAME_TAGS.stream().anyMatch(quest::contains)) {
-            quest += " Quest";
-        }
-
-        return quest;
-    }
-}
diff --git a/src/main/java/dinkplugin/message/DiscordMessageHandler.java b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
index 6381fe6..0da446e 100644
--- a/src/main/java/dinkplugin/message/DiscordMessageHandler.java
+++ b/src/main/java/dinkplugin/message/DiscordMessageHandler.java
@@ -3,7 +3,7 @@ package dinkplugin.message;
 import com.google.gson.Gson;
 import dinkplugin.DinkPlugin;
 import dinkplugin.DinkPluginConfig;
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import lombok.NonNull;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
diff --git a/src/main/java/dinkplugin/notifiers/BaseNotifier.java b/src/main/java/dinkplugin/notifiers/BaseNotifier.java
index 01b00b5..5f80d69 100644
--- a/src/main/java/dinkplugin/notifiers/BaseNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/BaseNotifier.java
@@ -1,9 +1,9 @@
 package dinkplugin.notifiers;
 
 import dinkplugin.DinkPluginConfig;
-import dinkplugin.Utils;
 import dinkplugin.message.DiscordMessageHandler;
 import dinkplugin.message.NotificationBody;
+import dinkplugin.util.WorldUtils;
 import net.runelite.api.Client;
 import org.apache.commons.lang3.StringUtils;
 
@@ -21,7 +21,7 @@ public abstract class BaseNotifier {
     private DiscordMessageHandler messageHandler;
 
     public boolean isEnabled() {
-        return !Utils.isIgnoredWorld(client.getWorldType());
+        return !WorldUtils.isIgnoredWorld(client.getWorldType());
     }
 
     protected abstract String getWebhookUrl();
diff --git a/src/main/java/dinkplugin/notifiers/ClueNotifier.java b/src/main/java/dinkplugin/notifiers/ClueNotifier.java
index 3f0e69e..0d24528 100644
--- a/src/main/java/dinkplugin/notifiers/ClueNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/ClueNotifier.java
@@ -2,7 +2,8 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.ItemUtils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.ClueNotificationData;
 import dinkplugin.notifiers.data.SerializedItemStack;
 import net.runelite.api.ItemComposition;
@@ -130,7 +131,7 @@ public class ClueNotifier extends BaseNotifier {
 
     private String getItemMessage(SerializedItemStack item, Collection<NotificationBody.Embed> embeds) {
         if (config.clueShowItems())
-            embeds.add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(Utils.getItemImageUrl(item.getId()))));
+            embeds.add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(ItemUtils.getItemImageUrl(item.getId()))));
         return String.format("%s x %s (%s)", item.getQuantity(), item.getName(), QuantityFormatter.quantityToStackSize(item.getTotalPrice()));
     }
 
diff --git a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
index 5c36e49..79f6649 100644
--- a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
@@ -2,7 +2,7 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.CollectionNotificationData;
 import org.apache.commons.lang3.StringUtils;
 import org.jetbrains.annotations.VisibleForTesting;
diff --git a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
index a174db1..8ac3429 100644
--- a/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CombatTaskNotifier.java
@@ -1,6 +1,6 @@
 package dinkplugin.notifiers;
 
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import dinkplugin.domain.CombatAchievementTier;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
diff --git a/src/main/java/dinkplugin/notifiers/DeathNotifier.java b/src/main/java/dinkplugin/notifiers/DeathNotifier.java
index 1850dea..39f993e 100644
--- a/src/main/java/dinkplugin/notifiers/DeathNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/DeathNotifier.java
@@ -1,10 +1,12 @@
 package dinkplugin.notifiers;
 
-import dinkplugin.Utils;
+import dinkplugin.util.ItemUtils;
+import dinkplugin.util.Utils;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.DeathNotificationData;
 import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.util.WorldUtils;
 import net.runelite.api.Actor;
 import net.runelite.api.Item;
 import net.runelite.api.ItemID;
@@ -52,7 +54,7 @@ public class DeathNotifier extends BaseNotifier {
 
     @Override
     public boolean isEnabled() {
-        return config.notifyDeath() && super.isEnabled() && !Utils.isSafeArea(client);
+        return config.notifyDeath() && super.isEnabled() && !WorldUtils.isSafeArea(client);
     }
 
     @Override
@@ -79,7 +81,7 @@ public class DeathNotifier extends BaseNotifier {
     private void handleNotify() {
         Actor pker = identifyPker();
 
-        Collection<Item> items = Utils.getItems(client);
+        Collection<Item> items = ItemUtils.getItems(client);
         List<Pair<Item, Long>> itemsByPrice = getPricedItems(itemManager, items);
 
         int keepCount = getKeepCount();
@@ -104,11 +106,11 @@ public class DeathNotifier extends BaseNotifier {
         List<SerializedItemStack> topLostStacks = getTopLostStacks(itemManager, lostItems, topLostItemIds);
         List<SerializedItemStack> keptStacks = keptItems.stream()
             .map(Pair::getKey)
-            .map(item -> Utils.stackFromItem(itemManager, item))
+            .map(item -> ItemUtils.stackFromItem(itemManager, item))
             .collect(Collectors.toList());
         List<NotificationBody.Embed> keptItemEmbeds;
         if (config.deathEmbedKeptItems()) {
-            keptItemEmbeds = Utils.buildEmbeds(
+            keptItemEmbeds = ItemUtils.buildEmbeds(
                 keptItems.stream()
                     .map(Pair::getKey)
                     .mapToInt(Item::getId)
@@ -175,11 +177,11 @@ public class DeathNotifier extends BaseNotifier {
     @Nullable
     private Player identifyPker() {
         // cannot be pk'd in safe zone
-        if (Utils.isPvpSafeZone(client))
+        if (WorldUtils.isPvpSafeZone(client))
             return null;
 
         // must be in wildness or pvp world to be pk'd
-        if (client.getVarbitValue(Varbits.IN_WILDERNESS) <= 0 && !Utils.isPvpWorld(client.getWorldType()))
+        if (client.getVarbitValue(Varbits.IN_WILDERNESS) <= 0 && !WorldUtils.isPvpWorld(client.getWorldType()))
             return null;
 
         Player localPlayer = client.getLocalPlayer();
@@ -208,7 +210,7 @@ public class DeathNotifier extends BaseNotifier {
     @NotNull
     private static List<Pair<Item, Long>> getPricedItems(ItemManager itemManager, Collection<Item> items) {
         return items.stream()
-            .map(item -> Pair.of(item, Utils.getPrice(itemManager, item.getId()) * item.getQuantity()))
+            .map(item -> Pair.of(item, ItemUtils.getPrice(itemManager, item.getId()) * item.getQuantity()))
             .sorted((a, b) -> Math.toIntExact(b.getRight() - a.getRight()))
             .collect(Collectors.toList());
     }
@@ -238,7 +240,7 @@ public class DeathNotifier extends BaseNotifier {
                 continue;
             }
 
-            if (kept < keepCount && !Utils.isItemNeverKeptOnDeath(id)) {
+            if (kept < keepCount && !ItemUtils.isItemNeverKeptOnDeath(id)) {
                 keep.add(item);
                 kept++;
             } else {
@@ -260,11 +262,11 @@ public class DeathNotifier extends BaseNotifier {
      */
     @NotNull
     private static List<SerializedItemStack> getTopLostStacks(ItemManager itemManager, List<Pair<Item, Long>> lostItems, int[] topLostItemIds) {
-        Map<Integer, Item> reducedLostItems = Utils.reduceItems(lostItems.stream().map(Pair::getLeft).collect(Collectors.toList()));
+        Map<Integer, Item> reducedLostItems = ItemUtils.reduceItems(lostItems.stream().map(Pair::getLeft).collect(Collectors.toList()));
         return Arrays.stream(topLostItemIds)
             .mapToObj(reducedLostItems::get)
             .filter(Objects::nonNull)
-            .map(item -> Utils.stackFromItem(itemManager, item))
+            .map(item -> ItemUtils.stackFromItem(itemManager, item))
             .collect(Collectors.toList());
     }
 
diff --git a/src/main/java/dinkplugin/notifiers/DiaryNotifier.java b/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
index 3ce78f2..72f31e1 100644
--- a/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/DiaryNotifier.java
@@ -1,6 +1,6 @@
 package dinkplugin.notifiers;
 
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import dinkplugin.domain.AchievementDiary;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
diff --git a/src/main/java/dinkplugin/notifiers/KillCountNotifier.java b/src/main/java/dinkplugin/notifiers/KillCountNotifier.java
index 7b3afe3..44c36b3 100644
--- a/src/main/java/dinkplugin/notifiers/KillCountNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/KillCountNotifier.java
@@ -1,6 +1,8 @@
 package dinkplugin.notifiers;
 
-import dinkplugin.Utils;
+import dinkplugin.util.ItemUtils;
+import dinkplugin.util.TimeUtils;
+import dinkplugin.util.Utils;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.BossNotificationData;
@@ -76,7 +78,7 @@ public class KillCountNotifier extends BaseNotifier {
         // Assemble content
         boolean isPb = data.isPersonalBest() == Boolean.TRUE;
         String player = Utils.getPlayerName(client);
-        String time = Utils.format(data.getTime(), Utils.isPreciseTiming(client));
+        String time = TimeUtils.format(data.getTime(), TimeUtils.isPreciseTiming(client));
         String content = StringUtils.replaceEach(
             isPb ? config.killCountBestTimeMessage() : config.killCountMessage(),
             new String[] { "%USERNAME%", "%BOSS%", "%COUNT%", "%TIME%" },
@@ -100,7 +102,7 @@ public class KillCountNotifier extends BaseNotifier {
                 .filter(npc -> data.getBoss().equalsIgnoreCase(npc.getName()))
                 .findAny()
                 .map(NPC::getId)
-                .map(Utils::getNpcImageUrl)
+                .map(ItemUtils::getNpcImageUrl)
                 .map(NotificationBody.UrlEmbed::new)
                 .map(NotificationBody.Embed::new)
                 .map(Collections::singletonList)
@@ -148,7 +150,7 @@ public class KillCountNotifier extends BaseNotifier {
     private static Optional<Pair<Duration, Boolean>> parseTime(String message) {
         Matcher matcher = TIME_REGEX.matcher(message);
         if (matcher.find()) {
-            Duration duration = Utils.parseTime(matcher.group("time"));
+            Duration duration = TimeUtils.parseTime(matcher.group("time"));
             boolean pb = message.toLowerCase().contains("(new personal best)");
             return Optional.of(Pair.of(duration, pb));
         }
diff --git a/src/main/java/dinkplugin/notifiers/LevelNotifier.java b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
index 3ff8bc3..0cccb99 100644
--- a/src/main/java/dinkplugin/notifiers/LevelNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LevelNotifier.java
@@ -2,7 +2,7 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.LevelNotificationData;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Experience;
diff --git a/src/main/java/dinkplugin/notifiers/LootNotifier.java b/src/main/java/dinkplugin/notifiers/LootNotifier.java
index 0fb7ec3..eb9235b 100644
--- a/src/main/java/dinkplugin/notifiers/LootNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/LootNotifier.java
@@ -2,9 +2,11 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.ItemUtils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.LootNotificationData;
 import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.util.WorldUtils;
 import net.runelite.api.ItemComposition;
 import net.runelite.client.events.NpcLootReceived;
 import net.runelite.client.events.PlayerLootReceived;
@@ -41,7 +43,7 @@ public class LootNotifier extends BaseNotifier {
     }
 
     public void onPlayerLootReceived(PlayerLootReceived event) {
-        if (Utils.isCastleWars(client) || Utils.isLastManStanding(client) || Utils.isSoulWars(client))
+        if (WorldUtils.isCastleWars(client) || WorldUtils.isLastManStanding(client) || WorldUtils.isSoulWars(client))
             return;
 
         if (config.includePlayerLoot() && isEnabled())
@@ -61,7 +63,7 @@ public class LootNotifier extends BaseNotifier {
         final int minValue = config.minLootValue();
         final boolean icons = config.lootIcons();
 
-        Collection<ItemStack> reduced = Utils.reduceItemStack(items);
+        Collection<ItemStack> reduced = ItemUtils.reduceItemStack(items);
         List<SerializedItemStack> serializedItems = new ArrayList<>(reduced.size());
         List<NotificationBody.Embed> embeds = new ArrayList<>(icons ? reduced.size() : 0);
 
@@ -79,7 +81,7 @@ public class LootNotifier extends BaseNotifier {
                 sendMessage = true;
                 if (lootMessage.length() > 0) lootMessage.append("\n");
                 lootMessage.append(String.format("%s x %s (%s)", quantity, itemComposition.getName(), QuantityFormatter.quantityToStackSize(totalPrice)));
-                if (icons) embeds.add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(Utils.getItemImageUrl(itemId))));
+                if (icons) embeds.add(new NotificationBody.Embed(new NotificationBody.UrlEmbed(ItemUtils.getItemImageUrl(itemId))));
             }
             serializedItems.add(new SerializedItemStack(itemId, quantity, price, itemComposition.getName()));
             totalStackValue += totalPrice;
diff --git a/src/main/java/dinkplugin/notifiers/PetNotifier.java b/src/main/java/dinkplugin/notifiers/PetNotifier.java
index a77bd2e..2e8a1ce 100644
--- a/src/main/java/dinkplugin/notifiers/PetNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/PetNotifier.java
@@ -2,7 +2,7 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import org.jetbrains.annotations.VisibleForTesting;
 
 import java.util.regex.Pattern;
diff --git a/src/main/java/dinkplugin/notifiers/QuestNotifier.java b/src/main/java/dinkplugin/notifiers/QuestNotifier.java
index dbee2c2..d0840ac 100644
--- a/src/main/java/dinkplugin/notifiers/QuestNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/QuestNotifier.java
@@ -2,7 +2,8 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.QuestUtils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.QuestNotificationData;
 import net.runelite.api.events.WidgetLoaded;
 import net.runelite.api.widgets.Widget;
@@ -33,7 +34,7 @@ public class QuestNotifier extends BaseNotifier {
     }
 
     private void handleNotify(String questText) {
-        String parsed = Utils.parseQuestWidget(questText);
+        String parsed = QuestUtils.parseQuestWidget(questText);
         String notifyMessage = StringUtils.replaceEach(
             config.questNotifyMessage(),
             new String[] { "%USERNAME%", "%QUEST%" },
diff --git a/src/main/java/dinkplugin/notifiers/SlayerNotifier.java b/src/main/java/dinkplugin/notifiers/SlayerNotifier.java
index 62f1225..ca8bd20 100644
--- a/src/main/java/dinkplugin/notifiers/SlayerNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/SlayerNotifier.java
@@ -2,7 +2,7 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.SlayerNotificationData;
 import org.apache.commons.lang3.StringUtils;
 import org.jetbrains.annotations.VisibleForTesting;
diff --git a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
index 5b47e94..aa6950a 100644
--- a/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/SpeedrunNotifier.java
@@ -2,7 +2,9 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
-import dinkplugin.Utils;
+import dinkplugin.util.QuestUtils;
+import dinkplugin.util.TimeUtils;
+import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.SpeedrunNotificationData;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.events.WidgetLoaded;
@@ -35,7 +37,7 @@ public class SpeedrunNotifier extends BaseNotifier {
             Widget duration = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_DURATION_CHILD_ID);
             Widget personalBest = client.getWidget(SPEEDRUN_COMPLETED_GROUP_ID, SPEEDRUN_COMPLETED_PB_CHILD_ID);
             if (questName != null && duration != null && personalBest != null) {
-                this.attemptNotify(Utils.parseQuestWidget(questName.getText()), duration.getText(), personalBest.getText());
+                this.attemptNotify(QuestUtils.parseQuestWidget(questName.getText()), duration.getText(), personalBest.getText());
             } else {
                 log.error("Found speedrun finished widget (group id {}) but it is missing something, questName={}, duration={}, pb={}", SPEEDRUN_COMPLETED_GROUP_ID, questName, duration, personalBest);
             }
@@ -43,8 +45,8 @@ public class SpeedrunNotifier extends BaseNotifier {
     }
 
     private void attemptNotify(String questName, String duration, String pb) {
-        Duration bestTime = Utils.parseTime(pb);
-        Duration currentTime = Utils.parseTime(duration);
+        Duration bestTime = TimeUtils.parseTime(pb);
+        Duration currentTime = TimeUtils.parseTime(duration);
         boolean isPb = bestTime.compareTo(currentTime) >= 0;
         if (!isPb && config.speedrunPBOnly()) {
             return;
diff --git a/src/main/java/dinkplugin/util/ItemUtils.java b/src/main/java/dinkplugin/util/ItemUtils.java
new file mode 100644
index 0000000..8d3c7af
--- /dev/null
+++ b/src/main/java/dinkplugin/util/ItemUtils.java
@@ -0,0 +1,119 @@
+package dinkplugin.util;
+
+import com.google.common.collect.ImmutableSet;
+import dinkplugin.message.NotificationBody;
+import dinkplugin.notifiers.data.SerializedItemStack;
+import lombok.experimental.UtilityClass;
+import net.runelite.api.Client;
+import net.runelite.api.InventoryID;
+import net.runelite.api.Item;
+import net.runelite.api.ItemComposition;
+import net.runelite.api.ItemContainer;
+import net.runelite.client.game.ItemManager;
+import net.runelite.client.game.ItemStack;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static net.runelite.api.ItemID.*;
+import static net.runelite.api.ItemID.TWISTED_BRONZE_TROPHY;
+import static net.runelite.api.ItemID.TWISTED_DRAGON_TROPHY;
+import static net.runelite.api.ItemID.TWISTED_IRON_TROPHY;
+import static net.runelite.api.ItemID.TWISTED_MITHRIL_TROPHY;
+import static net.runelite.api.ItemID.TWISTED_RUNE_TROPHY;
+import static net.runelite.api.ItemID.TWISTED_STEEL_TROPHY;
+
+@UtilityClass
+public class ItemUtils {
+
+    private final Set<Integer> NEVER_KEPT_ITEMS = ImmutableSet.of(
+        CLUE_BOX, LOOTING_BAG,
+        AMULET_OF_THE_DAMNED, AMULET_OF_THE_DAMNED_FULL,
+        BRACELET_OF_ETHEREUM, BRACELET_OF_ETHEREUM_UNCHARGED,
+        AVAS_ACCUMULATOR, AVAS_ATTRACTOR, MAGIC_SECATEURS,
+        SILLY_JESTER_HAT, SILLY_JESTER_TOP, SILLY_JESTER_TIGHTS, SILLY_JESTER_BOOTS,
+        LUNAR_HELM, LUNAR_TORSO, LUNAR_LEGS, LUNAR_GLOVES, LUNAR_BOOTS,
+        LUNAR_CAPE, LUNAR_AMULET, LUNAR_RING, LUNAR_STAFF,
+        SHATTERED_RELICS_ADAMANT_TROPHY, SHATTERED_RELICS_BRONZE_TROPHY, SHATTERED_RELICS_DRAGON_TROPHY,
+        SHATTERED_RELICS_IRON_TROPHY, SHATTERED_RELICS_MITHRIL_TROPHY, SHATTERED_RELICS_RUNE_TROPHY, SHATTERED_RELICS_STEEL_TROPHY,
+        TRAILBLAZER_ADAMANT_TROPHY, TRAILBLAZER_BRONZE_TROPHY, TRAILBLAZER_DRAGON_TROPHY, TRAILBLAZER_IRON_TROPHY,
+        TRAILBLAZER_MITHRIL_TROPHY, TRAILBLAZER_RUNE_TROPHY, TRAILBLAZER_STEEL_TROPHY,
+        TWISTED_ADAMANT_TROPHY, TWISTED_BRONZE_TROPHY, TWISTED_DRAGON_TROPHY, TWISTED_IRON_TROPHY,
+        TWISTED_MITHRIL_TROPHY, TWISTED_RUNE_TROPHY, TWISTED_STEEL_TROPHY
+    );
+
+    private final BinaryOperator<Item> SUM_ITEM_QUANTITIES = (a, b) -> new Item(a.getId(), a.getQuantity() + b.getQuantity());
+    private final BinaryOperator<ItemStack> SUM_ITEM_STACK_QUANTITIES = (a, b) -> new ItemStack(a.getId(), a.getQuantity() + b.getQuantity(), a.getLocation());
+
+    public boolean isItemNeverKeptOnDeath(int itemId) {
+        // https://oldschool.runescape.wiki/w/Items_Kept_on_Death#Items_that_are_never_kept
+        // https://oldschoolrunescape.fandom.com/wiki/Items_Kept_on_Death#Items_that_are_never_kept
+        return NEVER_KEPT_ITEMS.contains(itemId);
+    }
+
+    public long getPrice(ItemManager itemManager, int itemId) {
+        int price = itemManager.getItemPrice(itemId);
+        return price > 0 ? price : itemManager.getItemComposition(itemId).getPrice();
+    }
+
+    public Collection<Item> getItems(Client client) {
+        return Stream.of(InventoryID.INVENTORY, InventoryID.EQUIPMENT)
+            .map(client::getItemContainer)
+            .filter(Objects::nonNull)
+            .map(ItemContainer::getItems)
+            .flatMap(Arrays::stream)
+            .filter(Objects::nonNull)
+            .filter(item -> item.getId() >= 0) // -1 implies empty slot
+            .collect(Collectors.toList());
+    }
+
+    public <K, V> Map<K, V> reduce(@NotNull Iterable<V> items, Function<V, K> deriveKey, BinaryOperator<V> aggregate) {
+        final Map<K, V> map = new LinkedHashMap<>();
+        items.forEach(v -> map.merge(deriveKey.apply(v), v, aggregate));
+        return map;
+    }
+
+    public Map<Integer, Item> reduceItems(@NotNull Iterable<Item> items) {
+        return reduce(items, Item::getId, SUM_ITEM_QUANTITIES);
+    }
+
+    @NotNull
+    public Collection<ItemStack> reduceItemStack(@NotNull Iterable<ItemStack> items) {
+        return reduce(items, ItemStack::getId, SUM_ITEM_STACK_QUANTITIES).values();
+    }
+
+    public SerializedItemStack stackFromItem(ItemManager itemManager, Item item) {
+        int id = item.getId();
+        int quantity = item.getQuantity();
+        int price = itemManager.getItemPrice(id);
+        ItemComposition composition = itemManager.getItemComposition(id);
+        return new SerializedItemStack(id, quantity, price, composition.getName());
+    }
+
+    public String getItemImageUrl(int itemId) {
+        return "https://static.runelite.net/cache/item/icon/" + itemId + ".png";
+    }
+
+    public String getNpcImageUrl(int npcId) {
+        return String.format("https://chisel.weirdgloop.org/static/img/osrs-npc/%d_128.png", npcId);
+    }
+
+    public List<NotificationBody.Embed> buildEmbeds(int[] itemIds) {
+        return Arrays.stream(itemIds)
+            .mapToObj(ItemUtils::getItemImageUrl)
+            .map(NotificationBody.UrlEmbed::new)
+            .map(NotificationBody.Embed::new)
+            .collect(Collectors.toList());
+    }
+
+}
diff --git a/src/main/java/dinkplugin/util/QuestUtils.java b/src/main/java/dinkplugin/util/QuestUtils.java
new file mode 100644
index 0000000..cb556f3
--- /dev/null
+++ b/src/main/java/dinkplugin/util/QuestUtils.java
@@ -0,0 +1,79 @@
+/*
+ * Adapted from https://github.com/oliverpatrick/Enhanced-Discord-Notifications
+ * which is available under the following license:
+ *
+ * BSD 2-Clause License
+ *
+ * Copyright (c) 2021, William Winter
+ * 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 HOLDER 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 dinkplugin.util;
+
+import com.google.common.collect.ImmutableList;
+import lombok.experimental.UtilityClass;
+
+import java.util.Collection;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@UtilityClass
+public class QuestUtils {
+
+    private final Pattern QUEST_PATTERN_1 = Pattern.compile(".+?ve\\.*? (?<verb>been|rebuilt|.+?ed)? ?(?:the )?'?(?<quest>.+?)'?(?: [Qq]uest)?[!.]?$");
+    private final Pattern QUEST_PATTERN_2 = Pattern.compile("'?(?<quest>.+?)'?(?: [Qq]uest)? (?<verb>[a-z]\\w+?ed)?(?: f.*?)?[!.]?$");
+    private final Collection<String> RFD_TAGS = ImmutableList.of("Another Cook", "freed", "defeated", "saved");
+    private final Collection<String> WORD_QUEST_IN_NAME_TAGS = ImmutableList.of("Another Cook", "Doric", "Heroes", "Legends", "Observatory", "Olaf", "Waterfall");
+
+    public String parseQuestWidget(final String text) {
+        // "You have completed The Corsair Curse!"
+        final Matcher questMatch1 = QUEST_PATTERN_1.matcher(text);
+        // "'One Small Favour' completed!"
+        final Matcher questMatch2 = QUEST_PATTERN_2.matcher(text);
+        final Matcher questMatchFinal = questMatch1.matches() ? questMatch1 : questMatch2;
+        if (!questMatchFinal.matches()) {
+            return "Unable to find quest name!";
+        }
+
+        String quest = questMatchFinal.group("quest");
+        String verb = questMatchFinal.group("verb") != null ? questMatchFinal.group("verb") : "";
+
+        if (verb.contains("kind of")) {
+            quest += " partial completion";
+        } else if (verb.contains("completely")) {
+            quest += " II";
+        }
+
+        if (RFD_TAGS.stream().anyMatch((quest + verb)::contains)) {
+            quest = "Recipe for Disaster - " + quest;
+        }
+
+        if (WORD_QUEST_IN_NAME_TAGS.stream().anyMatch(quest::contains)) {
+            quest += " Quest";
+        }
+
+        return quest;
+    }
+
+}
diff --git a/src/main/java/dinkplugin/util/TimeUtils.java b/src/main/java/dinkplugin/util/TimeUtils.java
new file mode 100644
index 0000000..f41279e
--- /dev/null
+++ b/src/main/java/dinkplugin/util/TimeUtils.java
@@ -0,0 +1,78 @@
+package dinkplugin.util;
+
+import lombok.experimental.UtilityClass;
+import net.runelite.api.Client;
+import net.runelite.api.annotations.Varbit;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.time.Duration;
+import java.time.LocalTime;
+import java.time.temporal.Temporal;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static java.time.temporal.ChronoField.HOUR_OF_DAY;
+import static java.time.temporal.ChronoField.MILLI_OF_SECOND;
+import static java.time.temporal.ChronoField.MINUTE_OF_HOUR;
+import static java.time.temporal.ChronoField.SECOND_OF_MINUTE;
+
+@UtilityClass
+public class TimeUtils {
+
+    private final Pattern TIME_PATTERN = Pattern.compile("\\b(?:(?<hours>\\d+):)?(?<minutes>\\d+):(?<seconds>\\d{2})(?:\\.(?<fractional>\\d{2}))?\\b");
+
+    @VisibleForTesting
+    public final @Varbit int ENABLE_PRECISE_TIMING = 11866;
+
+    public boolean isPreciseTiming(@NotNull Client client) {
+        return client.getVarbitValue(ENABLE_PRECISE_TIMING) > 0;
+    }
+
+    @NotNull
+    public Duration parseTime(@NotNull String in) {
+        Matcher m = TIME_PATTERN.matcher(in);
+        if (!m.find()) return Duration.ZERO;
+
+        int minutes = Integer.parseInt(m.group("minutes"));
+        int seconds = Integer.parseInt(m.group("seconds"));
+
+        Duration d = Duration.ofMinutes(minutes).plusSeconds(seconds);
+
+        String hours = m.group("hours");
+        if (hours != null) {
+            d = d.plusHours(Integer.parseInt(hours));
+        }
+
+        String fractional = m.group("fractional");
+        if (fractional != null) {
+            // osrs sends 2 digits, but this is robust to changes
+            String f = fractional.length() < 3 ? StringUtils.rightPad(fractional, 3, '0') : fractional.substring(0, 3);
+            d = d.plusMillis(Integer.parseInt(f));
+        }
+
+        return d;
+    }
+
+    @NotNull
+    public String format(@Nullable Duration duration, boolean precise) {
+        Temporal time = ObjectUtils.defaultIfNull(duration, Duration.ZERO).addTo(LocalTime.of(0, 0));
+        StringBuilder sb = new StringBuilder();
+
+        int h = time.get(HOUR_OF_DAY);
+        if (h > 0)
+            sb.append(String.format("%02d", h)).append(':');
+
+        sb.append(String.format("%02d", time.get(MINUTE_OF_HOUR))).append(':');
+        sb.append(String.format("%02d", time.get(SECOND_OF_MINUTE)));
+
+        if (precise)
+            sb.append('.').append(String.format("%02d", time.get(MILLI_OF_SECOND) / 10));
+
+        return sb.toString();
+    }
+
+}
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
new file mode 100644
index 0000000..3ed9647
--- /dev/null
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -0,0 +1,46 @@
+package dinkplugin.util;
+
+import lombok.experimental.UtilityClass;
+import net.runelite.api.Client;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetInfo;
+import net.runelite.client.util.ColorUtil;
+import okhttp3.MediaType;
+import okhttp3.MultipartBody;
+import org.jetbrains.annotations.NotNull;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+@UtilityClass
+public class Utils {
+
+    public final Color PINK = ColorUtil.fromHex("#f40098"); // analogous to RED in CIELCh_uv color space
+    public final Color RED = ColorUtil.fromHex("#ca2a2d"); // red used in pajaW
+
+    public boolean isSettingsOpen(@NotNull Client client) {
+        Widget widget = client.getWidget(WidgetInfo.SETTINGS_INIT);
+        return widget != null && !widget.isSelfHidden();
+    }
+
+    public String getPlayerName(Client client) {
+        return client.getLocalPlayer().getName();
+    }
+
+    public byte[] convertImageToByteArray(BufferedImage bufferedImage) throws IOException {
+        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+        ImageIO.write(bufferedImage, "png", byteArrayOutputStream);
+        return byteArrayOutputStream.toByteArray();
+    }
+
+    public boolean hasImage(@NotNull MultipartBody body) {
+        return body.parts().stream().anyMatch(part -> {
+            MediaType type = part.body().contentType();
+            return type != null && "image".equals(type.type());
+        });
+    }
+
+}
diff --git a/src/main/java/dinkplugin/util/WorldUtils.java b/src/main/java/dinkplugin/util/WorldUtils.java
new file mode 100644
index 0000000..876792b
--- /dev/null
+++ b/src/main/java/dinkplugin/util/WorldUtils.java
@@ -0,0 +1,72 @@
+package dinkplugin.util;
+
+import com.google.common.collect.ImmutableSet;
+import lombok.experimental.UtilityClass;
+import net.runelite.api.Client;
+import net.runelite.api.WorldType;
+import net.runelite.api.annotations.Varbit;
+import net.runelite.api.widgets.Widget;
+import net.runelite.api.widgets.WidgetInfo;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Set;
+
+@UtilityClass
+public class WorldUtils {
+
+    private final Set<WorldType> IGNORED_WORLDS = EnumSet.of(WorldType.PVP_ARENA, WorldType.QUEST_SPEEDRUNNING, WorldType.NOSAVE_MODE, WorldType.TOURNAMENT_WORLD);
+
+    private final Set<Integer> LMS_REGIONS = ImmutableSet.of(13658, 13659, 13660, 13914, 13915, 13916, 13918, 13919, 13920, 14174, 14175, 14176, 14430, 14431, 14432);
+    private final Set<Integer> POH_REGIONS = ImmutableSet.of(7257, 7513, 7514, 7769, 7770, 8025, 8026);
+    private final Set<Integer> SOUL_REGIONS = ImmutableSet.of(8493, 8749, 9005);
+    private final int CASTLE_WARS_REGION = 9520;
+
+    @VisibleForTesting
+    public final int CASTLE_WARS_COUNTDOWN = 380;
+    @Varbit
+    private final int CASTLE_WARS_X_OFFSET = 156;
+    @Varbit
+    private final int CASTLE_WARS_Y_OFFSET = 157;
+
+    public boolean isIgnoredWorld(Set<WorldType> worldType) {
+        return !Collections.disjoint(IGNORED_WORLDS, worldType);
+    }
+
+    public boolean isPvpWorld(Set<WorldType> worldType) {
+        return worldType.contains(WorldType.PVP) || worldType.contains(WorldType.DEADMAN);
+    }
+
+    public boolean isPvpSafeZone(Client client) {
+        Widget widget = client.getWidget(WidgetInfo.PVP_WORLD_SAFE_ZONE);
+        return widget != null && !widget.isSelfHidden();
+    }
+
+    public boolean isCastleWars(Client client) {
+        return client.getLocalPlayer().getWorldLocation().getRegionID() == CASTLE_WARS_REGION &&
+            (client.getVarpValue(CASTLE_WARS_COUNTDOWN) > 0 || client.getVarbitValue(CASTLE_WARS_X_OFFSET) > 0 || client.getVarbitValue(CASTLE_WARS_Y_OFFSET) > 0);
+    }
+
+    public boolean isLastManStanding(Client client) {
+        return LMS_REGIONS.contains(client.getLocalPlayer().getWorldLocation().getRegionID());
+    }
+
+    public boolean isPestControl(Client client) {
+        Widget widget = client.getWidget(WidgetInfo.PEST_CONTROL_BLUE_SHIELD);
+        return widget != null;
+    }
+
+    public boolean isPlayerOwnedHouse(Client client) {
+        return POH_REGIONS.contains(client.getLocalPlayer().getWorldLocation().getRegionID());
+    }
+
+    public boolean isSafeArea(Client client) {
+        return isCastleWars(client) || isPestControl(client) || isPlayerOwnedHouse(client);
+    }
+
+    public boolean isSoulWars(Client client) {
+        return SOUL_REGIONS.contains(client.getLocalPlayer().getWorldLocation().getRegionID());
+    }
+
+}


Test patch:
diff --git a/src/test/java/dinkplugin/IgnoredWorldTest.java b/src/test/java/dinkplugin/IgnoredWorldTest.java
index a34f05f..c17fbc8 100644
--- a/src/test/java/dinkplugin/IgnoredWorldTest.java
+++ b/src/test/java/dinkplugin/IgnoredWorldTest.java
@@ -1,5 +1,6 @@
 package dinkplugin;
 
+import dinkplugin.util.WorldUtils;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -19,7 +20,7 @@ class IgnoredWorldTest {
     @ParameterizedTest(name = "World should not be ignored {0}")
     @ArgumentsSource(NonIgnoredWorldTypeProvider.class)
     void worldShouldNotBeIgnored(Set<WorldType> worldType) {
-        assertFalse(Utils.isIgnoredWorld(worldType));
+        assertFalse(WorldUtils.isIgnoredWorld(worldType));
     }
 
     private static class NonIgnoredWorldTypeProvider implements ArgumentsProvider {
@@ -37,7 +38,7 @@ class IgnoredWorldTest {
     @ParameterizedTest(name = "World should be ignored {0}")
     @ArgumentsSource(IgnoredWorldTypeProvider.class)
     void worldShouldBeIgnored(Set<WorldType> worldType) {
-        assertTrue(Utils.isIgnoredWorld(worldType));
+        assertTrue(WorldUtils.isIgnoredWorld(worldType));
     }
 
     private static class IgnoredWorldTypeProvider implements ArgumentsProvider {
diff --git a/src/test/java/dinkplugin/UtilsTest.java b/src/test/java/dinkplugin/UtilsTest.java
index f450b6e..c97c5a1 100644
--- a/src/test/java/dinkplugin/UtilsTest.java
+++ b/src/test/java/dinkplugin/UtilsTest.java
@@ -1,5 +1,6 @@
 package dinkplugin;
 
+import dinkplugin.util.ItemUtils;
 import org.junit.jupiter.api.extension.ExtensionContext;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -19,7 +20,7 @@ class UtilsTest {
     @ParameterizedTest(name = "Item stack should be reduced {0}")
     @ArgumentsSource(ItemStackReductionProvider.class)
     void itemStackShouldBeReduced(Collection<ItemStack> input, Collection<ItemStack> reduced) {
-        Collection<ItemStack> output = Utils.reduceItemStack(input);
+        Collection<ItemStack> output = ItemUtils.reduceItemStack(input);
         assertTrue(reduced.containsAll(output));
         assertTrue(output.containsAll(reduced));
     }
diff --git a/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java b/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java
index d72e1d3..97eef91 100644
--- a/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/DeathNotifierTest.java
@@ -1,11 +1,11 @@
 package dinkplugin.notifiers;
 
 import com.google.inject.testing.fieldbinder.Bind;
-import dinkplugin.Utils;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.DeathNotificationData;
 import dinkplugin.notifiers.data.SerializedItemStack;
+import dinkplugin.util.WorldUtils;
 import net.runelite.api.InventoryID;
 import net.runelite.api.Item;
 import net.runelite.api.ItemContainer;
@@ -229,7 +229,7 @@ class DeathNotifierTest extends MockedNotifierTest {
     void testIgnoreSafe() {
         // mock castle wars
         when(localPlayer.getWorldLocation()).thenReturn(new WorldPoint(2400, 3100, 0));
-        when(client.getVarpValue(Utils.CASTLE_WARS_COUNTDOWN)).thenReturn(10);
+        when(client.getVarpValue(WorldUtils.CASTLE_WARS_COUNTDOWN)).thenReturn(10);
 
         // fire event
         notifier.onActorDeath(new ActorDeath(localPlayer));
diff --git a/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java b/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
index 018a296..bf51b83 100644
--- a/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/KillCountNotifierTest.java
@@ -1,6 +1,6 @@
 package dinkplugin.notifiers;
 
-import dinkplugin.Utils;
+import dinkplugin.util.TimeUtils;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.BossNotificationData;
@@ -36,7 +36,7 @@ class KillCountNotifierTest extends MockedNotifierTest {
         when(config.killCountBestTimeMessage()).thenReturn("%USERNAME% has defeated %BOSS% with a new personal best time of %TIME% and a completion count of %COUNT%");
 
         // init client mocks
-        when(client.getVarbitValue(Utils.ENABLE_PRECISE_TIMING)).thenReturn(1);
+        when(client.getVarbitValue(TimeUtils.ENABLE_PRECISE_TIMING)).thenReturn(1);
     }
 
     @Test
@@ -178,7 +178,7 @@ class KillCountNotifierTest extends MockedNotifierTest {
     void testNotifyPbImprecise() {
         // more mocks
         when(config.killCountInterval()).thenReturn(99);
-        when(client.getVarbitValue(Utils.ENABLE_PRECISE_TIMING)).thenReturn(0);
+        when(client.getVarbitValue(TimeUtils.ENABLE_PRECISE_TIMING)).thenReturn(0);
 
         // fire events
         String gameMessage = "Your Zulrah kill count is: 13.";