diff --git a/.gitignore b/.gitignore index a28bff2..dd5d79b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ build .classpath nbactions.xml nb-configuration.xml -nbproject/ \ No newline at end of file +nbproject/ +.aider* diff --git a/CLAUDE.md b/CLAUDE.md index ddd407b..63f92b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,3 +38,18 @@ Bei **JEDEM** Update / Build müssen immer folgende Schritte ausgeführt werden: - **Wealth & Currency Store**: Vermögen, GE-Buy-Limits und Minigame-Währungen. - **Loot & Combat Tracker**: Drop Tables, Sessions, DPS-Rechner. - **Bank Highlighter**: Farbhervorhebung in Bank-Tabs. +- **Blast Furnace Helper** (`com.example.essentialsplus.helpers.BlastFurnace*`): + - Markiert strikt sequenziell immer nur genau ein Ziel ("niemals mehrere Dinge gleichzeitig"): + - Bei geöffneter Bank: Barren einlagern -> leere Vials einlagern -> bei <= 30 Energie Stamina Potion trinken (Priorität: 1-Dose Extended > 1-Dose Normal > 2-4 Dose Extended > 2-4 Dose Normal; angebrochene Tränke werden vor dem Erz-Abheben zurück in die Bank gelegt) -> Coal-Bag füllen im Inventar & Ziel-Erz/Kohle in der Bank abheben werden gleichzeitig markiert (sodass man Coal Bag füllen und direkt das Erz/Kohle anklicken kann; Mindestbestand im Ofen mit +1 Puffer, z. B. 4x Erz-Slots = 108 Kohle bei Runite mit Coal Bag; Priorität: Runite > Adamantite > Mithril > Gold > Iron/Steel > Silver > Bronze nach Smithing-Level & Bank-Bestand; Erze mit < 27 Stück in der Bank werden ignoriert, um halbe Touren zu verhindern; wenn alle Erze < 27 sind, ist der Helper fertig und markiert nichts mehr). + - Sobald das Inventar voll ist, wird das Förderband ("Put ore on belt" / "Put coal on belt") sofort in der 3D-Welt markiert – auch bei noch geöffneter Bank, sodass man mit 1 Klick direkt vom Bankfenster zum Band laufen kann. + - Intelligentes Warten am Bar Dispenser: Nach dem Auflegen von Erzen auf das Band wird der Bar Dispenser ("Wait for bars" / "Collect bars") markiert, bis die Barren geschmolzen und abgeholt sind, anstatt voreilig zur Bank-Chest zu leiten. Erst mit Barren im Inventar geht es zur Bank-Chest ("Bank bars"). + - Rand-Markierung ("Border-only"): Items in Bank & Inventar werden mit einem klaren Rahmen ohne deckenden Fill umrandet, sodass jedes Icon deutlich erkennbar bleibt. + - Einstellungen und Beschriftungen vollständig auf Englisch (Menüs im Helpers-Card per Rechtsklick, Panel-Zeilen, Tooltips). + - Info-Panel: Coal in furnace, Bars in furnace, Coffer + Restzeit und berechnete Barren aus Bank-Ores. +- **Chat Helper** (`com.example.essentialsplus.helpers.ChatHelper*`): + - Automatisches Aufräumen (Auto-Clear) alter Chat-Nachrichten nach konfigurierbarer Dauer (Standard 60s / 1 Min., 30s, 2m, 5m). Selektiver Schutz für private Nachrichten & Clan-/Friends-Chats. + - Duplikat-Kompaktierung: Aufeinanderfolgende gleiche Meldungen werden zu `(x2, x3...)` komprimiert. + - Spam- & Noise-Filter: Unterdrückt überflüssigen Systemlärm ("Nothing interesting happens", etc.) und Werbebots. + - Namen-Highlighting: Eigener Spielername wird in öffentlichen/Clan-Chats hervorgehoben (Farbe & Fettdruck). + - Rechtsklick-Aktionen: "Copy message" (kopiert bereinigten Text) & "Clear chat" auf Tabs / Kontextmenü. + diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java index 01b8e3f..947bdbf 100644 --- a/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java @@ -271,6 +271,17 @@ public interface EssentialsPlusConfig extends Config return false; } + @ConfigItem( + keyName = "blastFurnace", + name = "Blast Furnace helper", + description = "Highlights conveyor belt, bar dispenser, and bank chest, and shows coal in furnace, coffer time, and craftable bars from bank ores. Right-click the toggle for options.", + position = 38 + ) + default boolean blastFurnace() + { + return false; + } + @ConfigItem( keyName = "enemyAttackStyleText", name = "Enemy Attack Style: Text statt Icon", @@ -583,5 +594,140 @@ public interface EssentialsPlusConfig extends Config { return 0; } + + @ConfigItem( + keyName = "chatHelper", + name = "Chat helper", + description = "Automatisches Aufräumen alter Nachrichten (nach 1 Minute), Duplikate (x2, x3), Spam-Filter, Namen-Highlight & Kopieren.", + position = 90 + ) + default boolean chatHelper() + { + return false; + } + + @ConfigItem( + keyName = "chatAutoClear", + name = "Chat: Auto-Clear", + description = "Entfernt alte Nachrichten nach Ablauf der gewählten Zeit automatisch aus dem Chat.", + position = 91 + ) + default boolean chatAutoClear() + { + return true; + } + + @ConfigItem( + keyName = "chatAutoClearTime", + name = "Chat: Auto-Clear Zeit", + description = "Zeit in Sekunden, nach der Nachrichten automatisch verschwinden.", + position = 92 + ) + @Range(min = 10, max = 600) + @Units(Units.SECONDS) + default int chatAutoClearTime() + { + return 60; + } + + @ConfigItem( + keyName = "chatClearGame", + name = "Chat: Spiel-Nachrichten leeren", + description = "Spiel-, System- und Examine-Meldungen automatisch aufräumen.", + position = 93 + ) + default boolean chatClearGame() + { + return true; + } + + @ConfigItem( + keyName = "chatClearPublic", + name = "Chat: Öffentlichen Chat leeren", + description = "Öffentliche Spielernachrichten automatisch aufräumen.", + position = 94 + ) + default boolean chatClearPublic() + { + return true; + } + + @ConfigItem( + keyName = "chatClearPrivate", + name = "Chat: Private Nachrichten leeren", + description = "Private Nachrichten (PMs) ebenfalls aufräumen (Standard: Aus, um Konversationen zu behalten).", + position = 95 + ) + default boolean chatClearPrivate() + { + return false; + } + + @ConfigItem( + keyName = "chatClearClan", + name = "Chat: Clan- & Freunde-Chat leeren", + description = "Clan- und Gruppen-Nachrichten ebenfalls aufräumen (Standard: Aus).", + position = 96 + ) + default boolean chatClearClan() + { + return false; + } + + @ConfigItem( + keyName = "chatCollapseDuplicates", + name = "Chat: Duplikate zusammenfassen", + description = "Fasst aufeinanderfolgende gleiche Meldungen zu (x2, x3...) zusammen.", + position = 97 + ) + default boolean chatCollapseDuplicates() + { + return true; + } + + @ConfigItem( + keyName = "chatFilterSpam", + name = "Chat: Spam & Systemlärm filtern", + description = "Filtert repetitive, unnötige Systemmeldungen ('Nothing interesting happens' etc.) und Spam.", + position = 98 + ) + default boolean chatFilterSpam() + { + return true; + } + + @ConfigItem( + keyName = "chatHighlightName", + name = "Chat: Eigenen Namen hervorheben", + description = "Hebt deinen Spielernamen in Spielernachrichten farblich hervor.", + position = 99 + ) + default boolean chatHighlightName() + { + return true; + } + + @ConfigItem( + keyName = "chatHighlightColor", + name = "Chat: Highlight-Farbe", + description = "Farbe für die Namens-Hervorhebung.", + position = 100 + ) + default Color chatHighlightColor() + { + return new Color(0xFFD700); + } + + @ConfigItem( + keyName = "chatCopyToClipboard", + name = "Chat: Kopieren im Rechtsklick-Menü", + description = "Fügt beim Rechtsklick auf eine Chat-Nachricht die Option 'Copy message' hinzu.", + position = 101 + ) + default boolean chatCopyToClipboard() + { + return true; + } } + diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java index 2ac98b6..5590b76 100644 --- a/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java @@ -256,6 +256,21 @@ public class EssentialsPlusPlugin extends Plugin @Inject private com.example.essentialsplus.helpers.ItemChargesOverlay itemChargesOverlay; + @Inject + private com.example.essentialsplus.helpers.BlastFurnace blastFurnace; + + @Inject + private com.example.essentialsplus.helpers.BlastFurnaceOverlay blastFurnaceOverlay; + + @Inject + private com.example.essentialsplus.helpers.BlastFurnacePanelOverlay blastFurnacePanelOverlay; + + @Inject + private com.example.essentialsplus.helpers.BlastFurnaceItemsOverlay blastFurnaceItemsOverlay; + + @Inject + private com.example.essentialsplus.helpers.ChatHelper chatHelper; + @Inject private net.runelite.client.eventbus.EventBus eventBus; @@ -384,10 +399,17 @@ public class EssentialsPlusPlugin extends Plugin eventBus.register(attackStyles); eventBus.register(treeCooldowns); eventBus.register(itemCharges); + eventBus.register(blastFurnace); + eventBus.register(chatHelper); overlayManager.add(attackTimerOverlay); overlayManager.add(itemPriceOverlay); overlayManager.add(treeCooldownOverlay); overlayManager.add(itemChargesOverlay); + overlayManager.add(blastFurnaceOverlay); + overlayManager.add(blastFurnacePanelOverlay); + overlayManager.add(blastFurnaceItemsOverlay); + // only safe here: reading the config from the overlay constructor would run before the plugin fields exist + blastFurnacePanelOverlay.applyPosition(); currencyStore.load(); currencyTracker = new com.example.essentialsplus.currency.CurrencyTracker(client, currencyStore); skillSaveTask = executor.scheduleWithFixedDelay(() -> @@ -402,11 +424,7 @@ public class EssentialsPlusPlugin extends Plugin com.example.essentialsplus.loot.gear.MonsterService monsters = new com.example.essentialsplus.loot.gear.MonsterService( okHttpClient, gson, wikiExecutor, () -> fightService.onMonsterLoaded()); fightService = new com.example.essentialsplus.loot.gear.FightService(client, itemManager, combatTracker, attackStyles, - monsters, wikiExecutor, () -> - { - AccountData acc = activeKey != null ? store.get(activeKey) : null; - return acc != null ? acc.containers.get("bank") : null; - }); + monsters, wikiExecutor, this::getActiveBank); panel = new EssentialsPlusPanel(this); BufferedImage icon = loadIcon(); @@ -459,14 +477,21 @@ public class EssentialsPlusPlugin extends Plugin overlayManager.remove(itemPriceOverlay); overlayManager.remove(treeCooldownOverlay); overlayManager.remove(itemChargesOverlay); + overlayManager.remove(blastFurnaceOverlay); + overlayManager.remove(blastFurnacePanelOverlay); + overlayManager.remove(blastFurnaceItemsOverlay); eventBus.unregister(attackTimers); eventBus.unregister(attackStyles); eventBus.unregister(treeCooldowns); eventBus.unregister(itemCharges); + eventBus.unregister(blastFurnace); + eventBus.unregister(chatHelper); attackTimers.reset(); attackStyles.reset(); treeCooldowns.reset(); itemCharges.reset(); + blastFurnace.reset(); + chatHelper.reset(); if (skillSaveTask != null) { skillSaveTask.cancel(false); @@ -619,6 +644,10 @@ public class EssentialsPlusPlugin extends Plugin return config.treeCooldowns(); case "itemCharges": return config.itemCharges(); + case "blastFurnace": + return config.blastFurnace(); + case "chatHelper": + return config.chatHelper(); default: return false; } @@ -649,6 +678,18 @@ public class EssentialsPlusPlugin extends Plugin return itemCharges; } + public com.example.essentialsplus.helpers.ChatHelper getChatHelper() + { + return chatHelper; + } + + /** Last known bank contents of the logged in character (kept while the bank is closed), may be null. */ + public Map getActiveBank() + { + AccountData acc = activeKey != null ? store.get(activeKey) : null; + return acc != null ? acc.containers.get("bank") : null; + } + public net.runelite.client.ui.components.colorpicker.ColorPickerManager getColorPickerManager() { return colorPickerManager; @@ -1776,10 +1817,15 @@ public class EssentialsPlusPlugin extends Plugin { p.refreshSkillsLater(); } - if (p != null && (event.getKey().toLowerCase().contains("attacktimer") || event.getKey().toLowerCase().contains("attackstyle") || event.getKey().toLowerCase().contains("treecooldown") || event.getKey().toLowerCase().contains("itemcharge"))) + if ("blastFurnacePosition".equals(event.getKey())) + { + blastFurnacePanelOverlay.applyPosition(); + } + if (p != null && (event.getKey().toLowerCase().contains("attacktimer") || event.getKey().toLowerCase().contains("attackstyle") || event.getKey().toLowerCase().contains("treecooldown") || event.getKey().toLowerCase().contains("itemcharge") || event.getKey().toLowerCase().contains("blastfurnace") || event.getKey().toLowerCase().contains("chat"))) { p.refreshHelpersLater(); } + } } diff --git a/src/main/java/com/example/essentialsplus/helpers/BlastFurnace.java b/src/main/java/com/example/essentialsplus/helpers/BlastFurnace.java new file mode 100644 index 0000000..1f574d0 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/BlastFurnace.java @@ -0,0 +1,1097 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.Player; +import net.runelite.api.Skill; +import net.runelite.api.Tile; +import net.runelite.api.events.GameObjectDespawned; +import net.runelite.api.events.GameObjectSpawned; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.game.ItemManager; + +/** + * State of the Blast Furnace helper: + * + */ +@Singleton +public class BlastFurnace +{ + /** The Blast Furnace burns 72,000 gp per hour from the coffer (50 ms of time per gp). */ + public static final double COFFER_GP_PER_HOUR = 72000.0; + + /** Region of the Keldagrim Blast Furnace cave (the furnace varbits only mean something there). */ + public static final int REGION_ID = 7757; + + /** Bars that can be smelted at the Blast Furnace with the ores used for them. */ + public enum BarType + { + // label, bar item, bar varbit, coal per bar (Blast Furnace needs half the coal), ore items, ore amounts + BRONZE("Bronze", ItemID.BRONZE_BAR, VarbitID.BLAST_FURNACE_BRONZE_BARS, 0, + new int[]{ItemID.COPPER_ORE, ItemID.TIN_ORE}, new int[]{1, 1}), + IRON("Iron", ItemID.IRON_BAR, VarbitID.BLAST_FURNACE_IRON_BARS, 0, + new int[]{ItemID.IRON_ORE}, new int[]{1}), + STEEL("Steel", ItemID.STEEL_BAR, VarbitID.BLAST_FURNACE_STEEL_BARS, 1, + new int[]{ItemID.IRON_ORE}, new int[]{1}), + SILVER("Silver", ItemID.SILVER_BAR, VarbitID.BLAST_FURNACE_SILVER_BARS, 0, + new int[]{ItemID.SILVER_ORE}, new int[]{1}), + GOLD("Gold", ItemID.GOLD_BAR, VarbitID.BLAST_FURNACE_GOLD_BARS, 0, + new int[]{ItemID.GOLD_ORE}, new int[]{1}), + MITHRIL("Mithril", ItemID.MITHRIL_BAR, VarbitID.BLAST_FURNACE_MITHRIL_BARS, 2, + new int[]{ItemID.MITHRIL_ORE}, new int[]{1}), + ADAMANTITE("Adamantite", ItemID.ADAMANTITE_BAR, VarbitID.BLAST_FURNACE_ADAMANTITE_BARS, 3, + new int[]{ItemID.ADAMANTITE_ORE}, new int[]{1}), + RUNITE("Runite", ItemID.RUNITE_BAR, VarbitID.BLAST_FURNACE_RUNITE_BARS, 4, + new int[]{ItemID.RUNITE_ORE}, new int[]{1}), + LEAD("Lead", ItemID.LEAD_BAR, VarbitID.BLAST_FURNACE_LEAD_BARS, 0, + new int[]{ItemID.LEAD_ORE}, new int[]{2}), + CUPRONICKEL("Cupronickel", ItemID.CUPRONICKEL_BAR, VarbitID.BLAST_FURNACE_CUPRONICKEL_BARS, 0, + new int[]{ItemID.COPPER_ORE, ItemID.NICKEL_ORE}, new int[]{2, 1}); + + private final String label; + private final int barItemId; + private final int barVarbit; + private final int coalPerBar; + private final int[] oreItemIds; + private final int[] oreAmounts; + + BarType(String label, int barItemId, int barVarbit, int coalPerBar, int[] oreItemIds, int[] oreAmounts) + { + this.label = label; + this.barItemId = barItemId; + this.barVarbit = barVarbit; + this.coalPerBar = coalPerBar; + this.oreItemIds = oreItemIds; + this.oreAmounts = oreAmounts; + } + + public String getLabel() + { + return label; + } + + public int getBarItemId() + { + return barItemId; + } + + public int getBarVarbit() + { + return barVarbit; + } + + public int getCoalPerBar() + { + return coalPerBar; + } + + public int[] getOreItemIds() + { + return oreItemIds; + } + + public int[] getOreAmounts() + { + return oreAmounts; + } + } + + /** How many bars of one type the available ores + coal can still make. */ + public static final class Estimate + { + public final BarType type; + public final int bars; + /** true when less coal than ores is left, so coal is the bottleneck */ + public final boolean coalLimited; + + Estimate(BarType type, int bars, boolean coalLimited) + { + this.type = type; + this.bars = bars; + this.coalLimited = coalLimited; + } + } + + /** Every ore the Blast Furnace knows, including coal. */ + private static final Set ORE_IDS = new HashSet<>(); + /** Bar item id -> bar type. */ + private static final Map BAR_TYPES = new LinkedHashMap<>(); + + static + { + ORE_IDS.add(ItemID.COAL); + for (BarType type : BarType.values()) + { + BAR_TYPES.put(type.getBarItemId(), type); + for (int ore : type.getOreItemIds()) + { + ORE_IDS.add(ore); + } + } + } + + private final Client client; + private final ItemManager itemManager; + private final EssentialsPlusPlugin plugin; + private final ItemCharges itemCharges; + + private GameObject conveyorBelt; + private GameObject barDispenser; + private GameObject bankChest; + + /** Last tick the furnace objects were looked up in the scene. */ + private int scannedTick = -1; + + /** Cached estimates for the current tick (ore/coal counts are only read once per tick). */ + private List remainingCache; + private Set markableCache; + + private int lastPrimaryOreCount = 0; + private int oresSmeltingTick = -100; + private boolean oresSmelting = false; + + @Inject + public BlastFurnace(Client client, ItemManager itemManager, EssentialsPlusPlugin plugin, ItemCharges itemCharges) + { + this.client = client; + this.itemManager = itemManager; + this.plugin = plugin; + this.itemCharges = itemCharges; + } + + @Subscribe + public void onGameObjectSpawned(GameObjectSpawned event) + { + GameObject object = event.getGameObject(); + if (object == null) + { + return; + } + switch (object.getId()) + { + case ObjectID.BLAST_FURNACE_CONVEYER_BELT_CLICKABLE: + conveyorBelt = object; + break; + case ObjectID.BLAST_FURNACE_DISPENSER: + barDispenser = object; + break; + case ObjectID.BLAST_BANK_CHEST: + bankChest = object; + break; + default: + break; + } + } + + @Subscribe + public void onGameObjectDespawned(GameObjectDespawned event) + { + GameObject object = event.getGameObject(); + if (object == null) + { + return; + } + if (object == conveyorBelt || object.getId() == ObjectID.BLAST_FURNACE_CONVEYER_BELT_CLICKABLE) + { + conveyorBelt = null; + } + if (object == barDispenser || object.getId() == ObjectID.BLAST_FURNACE_DISPENSER) + { + barDispenser = null; + } + if (object == bankChest || object.getId() == ObjectID.BLAST_BANK_CHEST) + { + bankChest = null; + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() == GameState.LOADING) + { + reset(); + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + // the amounts change while smelting, so recalculate once per tick + remainingCache = null; + markableCache = null; + + if (inventoryHasBars()) + { + oresSmelting = false; + } + if (client.getTickCount() - oresSmeltingTick > 20) + { + oresSmelting = false; + } + lastPrimaryOreCount = countPrimaryOresInInventory(); + } + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + remainingCache = null; + markableCache = null; + + if (event.getContainerId() == InventoryID.INV) + { + int currentPrimaryOres = countPrimaryOresInInventory(); + if (lastPrimaryOreCount > 0 && currentPrimaryOres == 0 && !isBankOpen()) + { + oresSmelting = true; + oresSmeltingTick = client.getTickCount(); + } + lastPrimaryOreCount = currentPrimaryOres; + } + } + + public void reset() + { + conveyorBelt = null; + barDispenser = null; + bankChest = null; + scannedTick = -1; + remainingCache = null; + markableCache = null; + lastPrimaryOreCount = 0; + oresSmeltingTick = -100; + oresSmelting = false; + } + + public GameObject getConveyorBelt() + { + return conveyorBelt; + } + + public GameObject getBarDispenser() + { + return barDispenser; + } + + public GameObject getBankChest() + { + return bankChest; + } + + /** True while the furnace of the minigame is in the loaded scene. */ + public boolean isAtBlastFurnace() + { + ensureTracked(); + return conveyorBelt != null || barDispenser != null; + } + + /** True when the player stands in the Keldagrim Blast Furnace cave. */ + private boolean inRegion() + { + Player player = client.getLocalPlayer(); + return player != null && player.getWorldLocation() != null + && player.getWorldLocation().getRegionID() == REGION_ID; + } + + /** + * The objects are picked up from spawn events, which misses them when the helper is switched on + * while the player already stands at the furnace - so look them up in the scene (once per tick). + */ + private void ensureTracked() + { + if (conveyorBelt != null && barDispenser != null && bankChest != null) + { + return; + } + int tick = client.getTickCount(); + if (tick == scannedTick) + { + return; + } + scannedTick = tick; + if (!inRegion()) + { + return; + } + + Tile[][][] tiles = client.getScene().getTiles(); + int plane = client.getPlane(); + if (tiles == null || plane < 0 || plane >= tiles.length) + { + return; + } + for (Tile[] row : tiles[plane]) + { + if (row == null) + { + continue; + } + for (Tile tile : row) + { + if (tile == null || tile.getGameObjects() == null) + { + continue; + } + for (GameObject object : tile.getGameObjects()) + { + if (object == null) + { + continue; + } + switch (object.getId()) + { + case ObjectID.BLAST_FURNACE_CONVEYER_BELT_CLICKABLE: + if (conveyorBelt == null) + { + conveyorBelt = object; + } + break; + case ObjectID.BLAST_FURNACE_DISPENSER: + if (barDispenser == null) + { + barDispenser = object; + } + break; + case ObjectID.BLAST_BANK_CHEST: + if (bankChest == null) + { + bankChest = object; + } + break; + default: + break; + } + } + } + } + } + + // ------------------------------------------------------------- furnace contents + + /** Coal currently sitting inside the furnace. */ + public int coalInFurnace() + { + return client.getVarbitValue(VarbitID.BLAST_FURNACE_COAL); + } + + /** Bars currently inside the furnace / waiting in the bar dispenser, in bar type order. */ + public Map barsInFurnace() + { + Map bars = new LinkedHashMap<>(); + for (BarType type : BarType.values()) + { + int amount = client.getVarbitValue(type.getBarVarbit()); + if (amount > 0) + { + bars.put(type, amount); + } + } + return bars; + } + + /** True when at least one bar is waiting to be collected. */ + public boolean barsReady() + { + for (BarType type : BarType.values()) + { + if (client.getVarbitValue(type.getBarVarbit()) > 0) + { + return true; + } + } + return false; + } + + /** gp inside the Blast Furnace coffer. */ + public long cofferGp() + { + return client.getVarbitValue(VarbitID.BLAST_FURNACE_COFFER); + } + + /** Milliseconds of furnace time the given amount of gp still pays for. */ + public static long cofferMillis(long gp) + { + return (long) (Math.max(0, gp) / COFFER_GP_PER_HOUR * 3_600_000L); + } + + // ------------------------------------------------------------- inventory / bank + + public boolean inventoryHasOres() + { + return containerHas(client.getItemContainer(InventoryID.INV), ORE_IDS); + } + + public boolean inventoryHasBars() + { + return containerHas(client.getItemContainer(InventoryID.INV), BAR_TYPES.keySet()); + } + + public boolean isBankOpen() + { + Widget w = client.getWidget(ComponentID.BANK_CONTAINER); + return w != null && !w.isHidden(); + } + + public boolean hasCoalBag() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return false; + } + for (Item item : inv.getItems()) + { + if (item != null && (item.getId() == ItemID.COAL_BAG || item.getId() == ItemID.COAL_BAG_OPEN)) + { + return true; + } + } + return false; + } + + public int getCoalBagCapacity() + { + return itemCharges == null ? 27 : itemCharges.getCoalBagCapacity(); + } + + public boolean isCoalBagFull() + { + if (!hasCoalBag()) + { + return false; + } + return bagCoal() >= getCoalBagCapacity(); + } + + public boolean coalBagNeedsFill() + { + if (!hasCoalBag() || isCoalBagFull()) + { + return false; + } + BarType target = getTargetBarType(); + if (target == null || target.getCoalPerBar() <= 0) + { + return false; + } + return bankOreCount(ItemID.COAL) > 0; + } + + public boolean isInventoryFull() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return false; + } + int count = 0; + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + count++; + } + } + return count >= 28; + } + + public int bankOreCount(int itemId) + { + ItemContainer bank = client.getItemContainer(InventoryID.BANK); + if (bank != null && bank.size() > 0) + { + int count = 0; + for (Item item : bank.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + if (isPlaceholder(item.getId())) + { + continue; + } + int id = itemManager.canonicalize(item.getId()); + if (id == itemId) + { + count += item.getQuantity(); + } + } + } + return count; + } + Map saved = plugin.getActiveBank(); + if (saved != null) + { + Long val = saved.get(itemId); + return val != null ? (int) Math.min(Integer.MAX_VALUE, val) : 0; + } + return 0; + } + + public static final int[] STAMINA_POTION_PRIORITY = { + ItemID._1DOSE2STAMINA, + ItemID._1DOSESTAMINA, + ItemID._2DOSE2STAMINA, + ItemID._3DOSE2STAMINA, + ItemID._4DOSE2STAMINA, + ItemID._2DOSESTAMINA, + ItemID._3DOSESTAMINA, + ItemID._4DOSESTAMINA + }; + + public static boolean isStaminaPotion(int itemId) + { + for (int id : STAMINA_POTION_PRIORITY) + { + if (id == itemId) + { + return true; + } + } + return false; + } + + public boolean inventoryHasStaminaPotion() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return false; + } + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0 && isStaminaPotion(item.getId())) + { + return true; + } + } + return false; + } + + public int getInventoryStaminaPotion() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return -1; + } + for (int potId : STAMINA_POTION_PRIORITY) + { + for (Item item : inv.getItems()) + { + if (item != null && item.getId() == potId && item.getQuantity() > 0) + { + return potId; + } + } + } + return -1; + } + + public boolean inventoryHasVials() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return false; + } + for (Item item : inv.getItems()) + { + if (item != null && item.getId() == ItemID.VIAL_EMPTY && item.getQuantity() > 0) + { + return true; + } + } + return false; + } + + public int getTargetBankStaminaPotion() + { + for (int potId : STAMINA_POTION_PRIORITY) + { + if (bankOreCount(potId) > 0) + { + return potId; + } + } + return -1; + } + + public int getEnergy() + { + return client.getEnergy() / 100; + } + + public boolean needsStamina() + { + if (getTargetBarType() == null) + { + return false; + } + if (getEnergy() > 30) + { + return false; + } + if (inventoryHasStaminaPotion()) + { + return true; + } + return getTargetBankStaminaPotion() != -1; + } + + public static boolean isPrimaryOre(int itemId) + { + return itemId == ItemID.RUNITE_ORE + || itemId == ItemID.ADAMANTITE_ORE + || itemId == ItemID.MITHRIL_ORE + || itemId == ItemID.GOLD_ORE + || itemId == ItemID.IRON_ORE + || itemId == ItemID.SILVER_ORE + || itemId == ItemID.COPPER_ORE + || itemId == ItemID.TIN_ORE + || itemId == ItemID.LEAD_ORE + || itemId == ItemID.NICKEL_ORE; + } + + public int countPrimaryOresInInventory() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return 0; + } + int count = 0; + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0 && isPrimaryOre(item.getId())) + { + count += item.getQuantity(); + } + } + return count; + } + + public int unsmeltedOresInFurnace() + { + return client.getVarbitValue(VarbitID.BLAST_FURNACE_RUNITE_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_ADAMANTITE_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_MITHRIL_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_GOLD_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_IRON_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_SILVER_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_COPPER_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_TIN_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_LEAD_ORE) + + client.getVarbitValue(VarbitID.BLAST_FURNACE_NICKEL_ORE); + } + + public boolean isWaitingForBars() + { + if (barsReady()) + { + return true; + } + if (unsmeltedOresInFurnace() > 0) + { + return true; + } + return oresSmelting && (client.getTickCount() - oresSmeltingTick <= 15); + } + + public boolean inventoryHasCoal() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return false; + } + for (Item item : inv.getItems()) + { + if (item != null && item.getId() == ItemID.COAL && item.getQuantity() > 0) + { + return true; + } + } + return false; + } + + public int getInventoryCapacityForOre() + { + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv == null) + { + return 27; + } + int persistent = 0; + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + int id = item.getId(); + if (!isBar(id) && !isOre(id)) + { + persistent++; + } + } + } + return Math.max(1, 28 - persistent); + } + + public static int getRequiredFurnaceCoal(BarType type, int oreSlots, boolean hasCoalBag) + { + if (type == null || type.getCoalPerBar() <= 0) + { + return 0; + } + int bagContribution = hasCoalBag ? 1 : 0; + // +1 buffer: 4x for runite with coal bag (instead of 3x), and +1 buffer for other bars + int multiplier = Math.max(0, type.getCoalPerBar() - bagContribution) + 1; + return multiplier * Math.max(1, oreSlots); + } + + public int getRequiredFurnaceCoal(BarType type) + { + return getRequiredFurnaceCoal(type, getInventoryCapacityForOre(), hasCoalBag()); + } + + public static int getPrimaryOre(BarType type) + { + if (type == null) + { + return -1; + } + switch (type) + { + case RUNITE: + return ItemID.RUNITE_ORE; + case ADAMANTITE: + return ItemID.ADAMANTITE_ORE; + case MITHRIL: + return ItemID.MITHRIL_ORE; + case GOLD: + return ItemID.GOLD_ORE; + case STEEL: + case IRON: + return ItemID.IRON_ORE; + case SILVER: + return ItemID.SILVER_ORE; + case BRONZE: + return ItemID.COPPER_ORE; + case LEAD: + return ItemID.LEAD_ORE; + case CUPRONICKEL: + return ItemID.COPPER_ORE; + default: + return type.getOreItemIds().length > 0 ? type.getOreItemIds()[0] : -1; + } + } + + public static final int MIN_ORE_BATCH = 27; + + public static BarType pickTargetBarType(int level, java.util.function.ToIntFunction bankStock) + { + if ((level >= 85 || level == 0) && bankStock.applyAsInt(ItemID.RUNITE_ORE) >= MIN_ORE_BATCH) + { + return BarType.RUNITE; + } + if ((level >= 70 || level == 0) && bankStock.applyAsInt(ItemID.ADAMANTITE_ORE) >= MIN_ORE_BATCH) + { + return BarType.ADAMANTITE; + } + if ((level >= 50 || level == 0) && bankStock.applyAsInt(ItemID.MITHRIL_ORE) >= MIN_ORE_BATCH) + { + return BarType.MITHRIL; + } + if ((level >= 40 || level == 0) && bankStock.applyAsInt(ItemID.GOLD_ORE) >= MIN_ORE_BATCH) + { + return BarType.GOLD; + } + if ((level >= 30 || level == 0) && bankStock.applyAsInt(ItemID.IRON_ORE) >= MIN_ORE_BATCH) + { + return BarType.STEEL; + } + if ((level >= 20 || level == 0) && bankStock.applyAsInt(ItemID.SILVER_ORE) >= MIN_ORE_BATCH) + { + return BarType.SILVER; + } + if ((level >= 15 || level == 0) && bankStock.applyAsInt(ItemID.IRON_ORE) >= MIN_ORE_BATCH) + { + return BarType.IRON; + } + if (bankStock.applyAsInt(ItemID.COPPER_ORE) >= MIN_ORE_BATCH && bankStock.applyAsInt(ItemID.TIN_ORE) >= MIN_ORE_BATCH) + { + return BarType.BRONZE; + } + + BarType[] fallback = { + BarType.RUNITE, BarType.ADAMANTITE, BarType.MITHRIL, + BarType.GOLD, BarType.STEEL, BarType.SILVER, BarType.IRON, BarType.BRONZE + }; + for (BarType type : fallback) + { + boolean hasOres = true; + for (int ore : type.getOreItemIds()) + { + if (bankStock.applyAsInt(ore) < MIN_ORE_BATCH) + { + hasOres = false; + break; + } + } + if (hasOres && type.getOreItemIds().length > 0) + { + return type; + } + } + return null; + } + + public BarType getTargetBarType() + { + return pickTargetBarType(client.getRealSkillLevel(Skill.SMITHING), this::bankOreCount); + } + + public int getTargetBankOre() + { + return pickTargetBankOre( + client.getRealSkillLevel(Skill.SMITHING), + coalInFurnace(), + getInventoryCapacityForOre(), + hasCoalBag(), + this::bankOreCount + ); + } + + public static int pickTargetBankOre( + int level, + int coalInFurnace, + int oreSlots, + boolean hasCoalBag, + java.util.function.ToIntFunction bankStock) + { + BarType target = pickTargetBarType(level, bankStock); + if (target == null) + { + return -1; + } + + int neededCoal = getRequiredFurnaceCoal(target, oreSlots, hasCoalBag); + if (neededCoal > 0 && coalInFurnace < neededCoal && bankStock.applyAsInt(ItemID.COAL) > 0) + { + return ItemID.COAL; + } + + return getPrimaryOre(target); + } + + /** True when the coal bag of the item charges helper holds coal. */ + public boolean bagHasCoal() + { + return bagCoal() > 0; + } + + private int bagCoal() + { + // only tracked while the item charges helper is switched on + return itemCharges == null ? 0 : itemCharges.getTrackedCount("coal_bag"); + } + + /** Ores (and coal) in inventory + bank, noted items counted as their unnoted item. */ + public Map availableOres() + { + Map ores = new HashMap<>(); + countOres(ores, client.getItemContainer(InventoryID.INV), true); + ItemContainer bank = client.getItemContainer(InventoryID.BANK); + if (bank != null && bank.size() > 0) + { + countOres(ores, bank, true); + } + else + { + // the game only sends the bank while it is open - fall back to the copy the plugin keeps + // in sync on every bank change (so it is never older than the container seen here) + Map saved = plugin.getActiveBank(); + if (saved != null) + { + for (Map.Entry entry : saved.entrySet()) + { + int id = itemManager.canonicalize(entry.getKey()); + if (id > 0 && ORE_IDS.contains(id) && entry.getValue() != null && entry.getValue() > 0) + { + ores.merge(id, (int) Math.min(Integer.MAX_VALUE, (long) entry.getValue()), Integer::sum); + } + } + } + } + return ores; + } + + /** Bars that can still be smelted from the ores in bank + inventory, most bars first. */ + public List remainingBars() + { + if (remainingCache == null) + { + Map ores = availableOres(); + remainingCache = estimates(ores, ores.getOrDefault(ItemID.COAL, 0) + bagCoal()); + } + return remainingCache; + } + + /** Ores worth marking: every ore (and coal) that belongs to a bar type you can still make. */ + public Set markableOres() + { + if (markableCache == null) + { + Set markable = new HashSet<>(); + for (Estimate estimate : remainingBars()) + { + if (estimate.bars <= 0) + { + continue; + } + for (int ore : estimate.type.getOreItemIds()) + { + markable.add(ore); + } + if (estimate.type.getCoalPerBar() > 0) + { + markable.add(ItemID.COAL); + } + } + markableCache = markable; + } + return markableCache; + } + + public static boolean isOre(int itemId) + { + return ORE_IDS.contains(itemId); + } + + public static boolean isBar(int itemId) + { + return BAR_TYPES.containsKey(itemId); + } + + public static BarType barTypeOf(int itemId) + { + return BAR_TYPES.get(itemId); + } + + private static List estimates(Map ores, int coal) + { + List list = new ArrayList<>(); + for (BarType type : BarType.values()) + { + Estimate estimate = estimate(type, ores, coal); + if (estimate.bars > 0) + { + list.add(estimate); + } + } + list.sort((a, b) -> Integer.compare(b.bars, a.bars)); + return list; + } + + /** Bars of {@code type} that {@code ores} and {@code coal} can make (0 if an ore is missing). */ + public static Estimate estimate(BarType type, Map ores, int coal) + { + int[] ids = type.getOreItemIds(); + int[] amounts = type.getOreAmounts(); + int bars = Integer.MAX_VALUE; + for (int i = 0; i < ids.length && i < amounts.length; i++) + { + int needed = Math.max(1, amounts[i]); + bars = Math.min(bars, ores.getOrDefault(ids[i], 0) / needed); + } + boolean coalLimited = false; + if (type.getCoalPerBar() > 0) + { + int byCoal = coal / type.getCoalPerBar(); + coalLimited = byCoal < bars; + bars = Math.min(bars, byCoal); + } + if (bars == Integer.MAX_VALUE || bars < 0) + { + bars = 0; + } + return new Estimate(type, bars, coalLimited); + } + + private void countOres(Map out, ItemContainer container, boolean canonicalize) + { + if (container == null) + { + return; + } + for (Item item : container.getItems()) + { + if (item == null || item.getId() <= 0 || item.getQuantity() <= 0) + { + continue; + } + int id = item.getId(); + if (!ORE_IDS.contains(id)) + { + if (isPlaceholder(id)) + { + continue; + } + id = canonicalize ? itemManager.canonicalize(id) : id; + if (!ORE_IDS.contains(id)) + { + continue; + } + } + out.merge(id, item.getQuantity(), Integer::sum); + } + } + + private boolean isPlaceholder(int itemId) + { + ItemComposition composition = itemManager.getItemComposition(itemId); + return composition != null && composition.getPlaceholderTemplateId() != -1; + } + + private static boolean containerHas(ItemContainer container, Set ids) + { + if (container == null) + { + return false; + } + for (Item item : container.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0 && ids.contains(item.getId())) + { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/BlastFurnaceItemsOverlay.java b/src/main/java/com/example/essentialsplus/helpers/BlastFurnaceItemsOverlay.java new file mode 100644 index 0000000..86da346 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/BlastFurnaceItemsOverlay.java @@ -0,0 +1,196 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import javax.inject.Inject; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetItem; +import net.runelite.api.widgets.WidgetUtil; +import net.runelite.client.ui.overlay.WidgetItemOverlay; + +/** + * Marks the active Blast Furnace item in bank and inventory: coal bag to fill, bars to deposit, + * or the target ore in the bank to withdraw. Only a single item type is marked at any time, + * with border-only highlighting to keep items clearly recognizable. + */ +public class BlastFurnaceItemsOverlay extends WidgetItemOverlay +{ + private final EssentialsPlusConfig config; + private final EssentialsPlusPlugin plugin; + private final BlastFurnace blastFurnace; + + @Inject + public BlastFurnaceItemsOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, BlastFurnace blastFurnace) + { + this.config = config; + this.plugin = plugin; + this.blastFurnace = blastFurnace; + showOnInventory(); + showOnBank(); + } + + @Override + public void renderItemOverlay(Graphics2D g, int itemId, WidgetItem widgetItem) + { + if (!config.blastFurnace() || !blastFurnace.isAtBlastFurnace()) + { + return; + } + + Widget widget = widgetItem.getWidget(); + if (widget == null) + { + return; + } + boolean bank = isBank(widget); + boolean bankOpen = blastFurnace.isBankOpen(); + + if (bankOpen) + { + // State 1: Deposit bars in inventory + if (blastFurnace.inventoryHasBars()) + { + if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true)) + { + return; + } + if (!BlastFurnace.isBar(itemId)) + { + return; + } + } + // State 2: Deposit empty vials in inventory + else if (blastFurnace.inventoryHasVials()) + { + if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true)) + { + return; + } + if (itemId != ItemID.VIAL_EMPTY) + { + return; + } + } + // State 3: Deposit leftover stamina potion in inventory (if energy is already > 30) + else if (!blastFurnace.needsStamina() && blastFurnace.inventoryHasStaminaPotion()) + { + if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true)) + { + return; + } + if (!BlastFurnace.isStaminaPotion(itemId)) + { + return; + } + } + // State 4: Drink stamina potion if energy <= 30 + else if (plugin.getHelperValue("blastFurnaceStamina", Boolean.class, true) && blastFurnace.needsStamina()) + { + if (blastFurnace.inventoryHasStaminaPotion()) + { + // Drink from inventory + if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true)) + { + return; + } + if (itemId != blastFurnace.getInventoryStaminaPotion()) + { + return; + } + } + else + { + // Withdraw from bank + if (!bank || !plugin.getHelperValue("blastFurnaceMarkBankItems", Boolean.class, true)) + { + return; + } + if (itemId != blastFurnace.getTargetBankStaminaPotion()) + { + return; + } + } + } + // State 5: Inventory is full -> conveyor belt is marked in 3D world, nothing in bank/inventory + else if (blastFurnace.isInventoryFull()) + { + return; + } + // State 6: Fill coal bag in inventory and withdraw target ore/coal from bank simultaneously + else + { + if (!bank) + { + // In inventory: highlight coal bag if it needs filling + if (!plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true)) + { + return; + } + if (!blastFurnace.coalBagNeedsFill()) + { + return; + } + if (itemId != ItemID.COAL_BAG && itemId != ItemID.COAL_BAG_OPEN) + { + return; + } + } + else + { + // In bank: highlight target ore / coal to withdraw + if (!plugin.getHelperValue("blastFurnaceMarkBankItems", Boolean.class, true)) + { + return; + } + int targetOre = blastFurnace.getTargetBankOre(); + if (targetOre == -1 || itemId != targetOre) + { + return; + } + } + } + } + else + { + // Bank is closed: + // If coal in bag and no ores in inventory (e.g. at belt to empty bag) + if (blastFurnace.bagHasCoal() && !blastFurnace.inventoryHasOres() && blastFurnace.hasCoalBag()) + { + if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true)) + { + return; + } + if (itemId != ItemID.COAL_BAG && itemId != ItemID.COAL_BAG_OPEN) + { + return; + } + } + else + { + return; + } + } + + Rectangle bounds = widgetItem.getCanvasBounds(); + if (bounds == null || bounds.width < 4 || bounds.height < 4) + { + return; + } + + Color color = plugin.getHelperValue("blastFurnaceColor", Color.class, BlastFurnaceOverlay.DEFAULT_MARK_COLOR); + g.setColor(color); + g.drawRect(bounds.x, bounds.y, bounds.width - 1, bounds.height - 1); + g.drawRect(bounds.x + 1, bounds.y + 1, bounds.width - 3, bounds.height - 3); + } + + private static boolean isBank(Widget widget) + { + int group = WidgetUtil.componentToInterface(widget.getId()); + return group == InterfaceID.BANKMAIN || group == 12; // 12 = InterfaceID.BANK + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/BlastFurnaceOverlay.java b/src/main/java/com/example/essentialsplus/helpers/BlastFurnaceOverlay.java new file mode 100644 index 0000000..2803f5f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/BlastFurnaceOverlay.java @@ -0,0 +1,157 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.ui.Ui; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Shape; +import javax.inject.Inject; +import net.runelite.api.GameObject; +import net.runelite.api.Point; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; + +/** + * Marks the Blast Furnace objects that are worth clicking right now: the conveyor belt while + * ores are waiting in the inventory, the bar dispenser when bars are ready and the bank chest + * when the bars in the inventory should be banked. + */ +public class BlastFurnaceOverlay extends Overlay +{ + public static final Color DEFAULT_MARK_COLOR = new Color(0xFFB300); + public static final Color DEFAULT_READY_COLOR = new Color(0x66FF66); + + private final EssentialsPlusConfig config; + private final EssentialsPlusPlugin plugin; + private final BlastFurnace blastFurnace; + + @Inject + public BlastFurnaceOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, BlastFurnace blastFurnace) + { + this.config = config; + this.plugin = plugin; + this.blastFurnace = blastFurnace; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D g) + { + if (!config.blastFurnace() || !blastFurnace.isAtBlastFurnace()) + { + return null; + } + + boolean bankOpen = blastFurnace.isBankOpen(); + Color markColor = plugin.getHelperValue("blastFurnaceColor", Color.class, DEFAULT_MARK_COLOR); + Color readyColor = plugin.getHelperValue("blastFurnaceReadyColor", Color.class, DEFAULT_READY_COLOR); + boolean labels = plugin.getHelperValue("blastFurnaceLabels", Boolean.class, true); + TimerTextSize size = plugin.getHelperValue("blastFurnaceTextSize", TimerTextSize.class, TimerTextSize.SMALL); + + // 1. Bars in inventory -> bank chest (only if bank is closed) + if (blastFurnace.inventoryHasBars()) + { + if (!bankOpen && plugin.getHelperValue("blastFurnaceMarkBank", Boolean.class, true)) + { + mark(g, blastFurnace.getBankChest(), markColor, labels ? "Bank bars" : null, size); + } + return null; + } + + // 2. Full inventory or ores in inventory -> conveyor belt (ALLOWED EVEN IF BANK IS OPEN!) + if (blastFurnace.isInventoryFull() || blastFurnace.inventoryHasOres()) + { + if (plugin.getHelperValue("blastFurnaceMarkBelt", Boolean.class, true)) + { + String label = blastFurnace.inventoryHasCoal() ? "Put coal on belt" : "Put ore on belt"; + mark(g, blastFurnace.getConveyorBelt(), markColor, labels ? label : null, size); + } + return null; + } + + // While bank is open and inventory is not full, player is interacting with bank items + if (bankOpen) + { + return null; + } + + // 3. Bars ready to collect or waiting for ores to smelt -> bar dispenser! + if (blastFurnace.isWaitingForBars()) + { + if (plugin.getHelperValue("blastFurnaceMarkDispenser", Boolean.class, true)) + { + boolean ready = blastFurnace.barsReady(); + Color color = ready ? readyColor : markColor; + String label = ready ? "Collect bars" : "Wait for bars"; + mark(g, blastFurnace.getBarDispenser(), color, labels ? label : null, size); + } + return null; + } + + // 4. Coal in coal bag to empty onto belt + if (blastFurnace.bagHasCoal() && blastFurnace.hasCoalBag()) + { + if (plugin.getHelperValue("blastFurnaceMarkBelt", Boolean.class, true)) + { + mark(g, blastFurnace.getConveyorBelt(), markColor, labels ? "Put coal on belt" : null, size); + } + return null; + } + + // 5. Otherwise open bank + if (plugin.getHelperValue("blastFurnaceMarkBank", Boolean.class, true)) + { + mark(g, blastFurnace.getBankChest(), markColor, labels ? "Open bank" : null, size); + } + + return null; + } + + private void mark(Graphics2D g, GameObject object, Color color, String label, TimerTextSize size) + { + if (object == null) + { + return; + } + + Shape shape = object.getClickbox(); + if (shape == null) + { + shape = object.getCanvasTilePoly(); + } + if (shape != null) + { + g.setColor(color); + g.draw(shape); + g.setColor(Ui.alpha(color, 45)); + g.fill(shape); + } + + if (label == null) + { + return; + } + + Font font = size.font(); + g.setFont(font); + Point anchor = object.getCanvasTextLocation(g, label, 30); + if (anchor == null) + { + return; + } + + FontMetrics fm = g.getFontMetrics(); + int x = anchor.getX() - fm.stringWidth(label) / 2; + int y = anchor.getY(); + g.setColor(Color.BLACK); + g.drawString(label, x + 1, y + 1); + g.setColor(color); + g.drawString(label, x, y); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/BlastFurnacePanelOverlay.java b/src/main/java/com/example/essentialsplus/helpers/BlastFurnacePanelOverlay.java new file mode 100644 index 0000000..e89da1c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/BlastFurnacePanelOverlay.java @@ -0,0 +1,140 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.ui.Ui; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import net.runelite.client.ui.overlay.OverlayPanel; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; + +/** + * Panel with the Blast Furnace numbers: coal in the furnace, bars waiting in the dispenser, + * coffer and remaining time, and how many bars the ores in the bank can still be turned into. + */ +public class BlastFurnacePanelOverlay extends OverlayPanel +{ + private final EssentialsPlusPlugin plugin; + private final EssentialsPlusConfig config; + private final BlastFurnace blastFurnace; + + @Inject + BlastFurnacePanelOverlay(EssentialsPlusPlugin plugin, EssentialsPlusConfig config, BlastFurnace blastFurnace) + { + super(plugin); + this.plugin = plugin; + this.config = config; + this.blastFurnace = blastFurnace; + setPosition(OverlayPosition.TOP_LEFT); + panelComponent.setPreferredSize(new Dimension(190, 0)); + } + + /** Moves the panel to the corner picked in the helper menu (kept out of render so dragging works). */ + public void applyPosition() + { + OverlayPosition position = plugin.getHelperValue("blastFurnacePosition", OverlayPosition.class, OverlayPosition.TOP_LEFT); + if (position != null) + { + setPosition(position); + } + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (!config.blastFurnace() || !blastFurnace.isAtBlastFurnace()) + { + return null; + } + if (!plugin.getHelperValue("blastFurnacePanel", Boolean.class, true)) + { + return null; + } + + List lines = new ArrayList<>(); + + if (plugin.getHelperValue("blastFurnaceShowCoal", Boolean.class, true)) + { + int coal = blastFurnace.coalInFurnace(); + BlastFurnace.BarType target = blastFurnace.getTargetBarType(); + int needed = target != null ? blastFurnace.getRequiredFurnaceCoal(target) : 0; + Color coalColor = (needed > 0 && coal < needed) ? Ui.WARN : Ui.TEXT; + String text = Ui.gp(coal) + (needed > 0 ? " / " + needed : ""); + lines.add(line("Coal in furnace", text, coalColor)); + } + + if (plugin.getHelperValue("blastFurnaceShowFurnaceBars", Boolean.class, true)) + { + blastFurnace.barsInFurnace().forEach((type, amount) -> + lines.add(line(type.getLabel() + " (Furnace)", Ui.gp(amount), Ui.UP))); + } + + long coffer = blastFurnace.cofferGp(); + if (plugin.getHelperValue("blastFurnaceShowCoffer", Boolean.class, true)) + { + lines.add(line("Coffer", Ui.gp(coffer) + " gp", Ui.GOLD)); + } + if (plugin.getHelperValue("blastFurnaceShowCofferTime", Boolean.class, true)) + { + long millis = BlastFurnace.cofferMillis(coffer); + Color color = millis < 5 * Ui.MINUTE ? Ui.DOWN : (millis < 30 * Ui.MINUTE ? Ui.WARN : Ui.TEXT); + lines.add(line("Coffer time", duration(millis), color)); + } + + if (plugin.getHelperValue("blastFurnaceShowBankBars", Boolean.class, true)) + { + int limit = Math.max(0, plugin.getHelperValue("blastFurnaceBankLines", Integer.class, 3)); + List estimates = blastFurnace.remainingBars(); + for (int i = 0; i < Math.min(limit, estimates.size()); i++) + { + BlastFurnace.Estimate estimate = estimates.get(i); + String value = Ui.gp(estimate.bars) + (estimate.coalLimited ? " (Coal)" : ""); + lines.add(line("Bank: " + estimate.type.getLabel(), value, estimate.coalLimited ? Ui.WARN : Ui.TEXT)); + } + } + + if (lines.isEmpty()) + { + return null; + } + + panelComponent.getChildren().add(TitleComponent.builder() + .text("Blast Furnace") + .color(Ui.ACCENT) + .build()); + panelComponent.getChildren().addAll(lines); + return super.render(graphics); + } + + private static LineComponent line(String left, String right, Color rightColor) + { + return LineComponent.builder() + .left(left) + .leftColor(Ui.MUTED) + .right(right) + .rightColor(rightColor) + .build(); + } + + private static String duration(long millis) + { + long seconds = Math.max(0, millis / 1000); + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + if (hours > 0) + { + return hours + "h " + minutes + "m"; + } + if (minutes > 0) + { + return minutes + "m " + (seconds % 60) + "s"; + } + return seconds + "s"; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java b/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java new file mode 100644 index 0000000..1b4bf7b --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java @@ -0,0 +1,578 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import java.awt.Color; +import java.awt.Toolkit; +import java.awt.datatransfer.StringSelection; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatLineBuffer; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.MenuAction; +import net.runelite.api.MessageNode; +import net.runelite.api.Player; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.MenuEntryAdded; +import net.runelite.api.events.ScriptCallbackEvent; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetUtil; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.util.ColorUtil; +import net.runelite.client.util.Text; + +/** + * Chat Helper: + * - Auto-clears messages after a configurable duration (e.g. 1 minute) to keep the chat tidy. + * - Collapses consecutive duplicate messages (x2, x3...). + * - Filters repetitive game noise and spam messages. + * - Highlights mentions of the player's name in speech chats. + * - Adds right-click "Copy message" and "Clear chat" options. + */ +@Slf4j +@Singleton +public class ChatHelper +{ + private static final Set NOISE_MESSAGES = Set.of( + "Nothing interesting happens.", + "You can't reach that.", + "You cannot reach that.", + "I can't reach that!", + "You can't do that here.", + "You cannot do that here.", + "You can't do that while moving.", + "You cannot do that while moving.", + "You can't do that right now.", + "You cannot do that right now.", + "You can't log out right now.", + "You don't have enough inventory space.", + "You do not have enough inventory space.", + "There is no player by that name.", + "That player is currently offline.", + "You are not currently in a clan.", + "You are not currently in a friends chat." + ); + + private static final Pattern NOISE_PREFIX_PATTERN = Pattern.compile( + "^(Your inventory is too full to hold any more|You need a higher level to|You need to be in a member's world to).*", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern PUBLIC_SPAM_PATTERN = Pattern.compile( + "(?i).*(giveaway|free gp|quitting.*search|search on yt|search yt|sub to my yt|2b for|swap cc|dicing cc|roll 55|autoclicker|osrsgold|buy gp).*" + ); + + private static class Duplicate + { + int messageId = -1; + int count = 1; + } + + private final Client client; + private final EssentialsPlusConfig config; + private final ClientThread clientThread; + + private final LinkedHashMap duplicateChatCache = new LinkedHashMap(100, 0.75f, true) + { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > 100; + } + }; + + private String lastConsecutiveKey = null; + private int lastCheckEpoch = 0; + + @Inject + public ChatHelper(Client client, EssentialsPlusConfig config, ClientThread clientThread) + { + this.client = client; + this.config = config; + this.clientThread = clientThread; + } + + public void reset() + { + duplicateChatCache.clear(); + lastConsecutiveKey = null; + lastCheckEpoch = 0; + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() == GameState.LOGIN_SCREEN || event.getGameState() == GameState.HOPPING) + { + reset(); + } + } + + /** + * Auto-clear expired chat messages based on configured retention time. + */ + @Subscribe + public void onGameTick(GameTick event) + { + if (!config.chatHelper() || !config.chatAutoClear()) + { + return; + } + + int timeoutSeconds = config.chatAutoClearTime(); + if (timeoutSeconds <= 0) + { + return; + } + + int now = (int) Instant.now().getEpochSecond(); + if (now - lastCheckEpoch < 1) + { + return; + } + lastCheckEpoch = now; + + Map chatLineMap = client.getChatLineMap(); + if (chatLineMap == null || chatLineMap.isEmpty()) + { + return; + } + + boolean removedAny = false; + for (ChatLineBuffer buffer : chatLineMap.values()) + { + if (buffer == null) + { + continue; + } + + MessageNode[] lines = buffer.getLines(); + if (lines == null || lines.length == 0) + { + continue; + } + + MessageNode[] copy = lines.clone(); + for (MessageNode node : copy) + { + if (node == null) + { + continue; + } + + int ts = node.getTimestamp(); + if (ts > 0 && (now - ts) >= timeoutSeconds && shouldClearType(node.getType())) + { + buffer.removeMessageNode(node); + removedAny = true; + } + } + } + + if (removedAny) + { + client.runScript(83); + } + } + + private boolean shouldClearType(ChatMessageType type) + { + if (type == null) + { + return false; + } + + switch (type) + { + case GAMEMESSAGE: + case ENGINE: + case ITEM_EXAMINE: + case NPC_EXAMINE: + case OBJECT_EXAMINE: + case SPAM: + case BROADCAST: + case CONSOLE: + return config.chatClearGame(); + + case PUBLICCHAT: + case MODCHAT: + case AUTOTYPER: + case TRADEREQ: + case TRADE: + return config.chatClearPublic(); + + case PRIVATECHAT: + case PRIVATECHATOUT: + case MODPRIVATECHAT: + return config.chatClearPrivate(); + + case CLAN_CHAT: + case CLAN_GUEST_CHAT: + case FRIENDSCHAT: + case CLAN_MESSAGE: + case CLAN_GIM_CHAT: + case CLAN_GIM_MESSAGE: + return config.chatClearClan(); + + default: + return false; + } + } + + /** + * Tracks incoming chat messages for duplicate collapsing. + */ + @Subscribe + public void onChatMessage(ChatMessage event) + { + if (!config.chatHelper() || !config.chatCollapseDuplicates()) + { + return; + } + + ChatMessageType type = event.getType(); + if (!isCollapsible(type)) + { + return; + } + + String name = event.getName() != null ? event.getName() : ""; + String message = event.getMessage(); + String key = name + ":" + type.ordinal() + ":" + message; + + if (!key.equals(lastConsecutiveKey)) + { + lastConsecutiveKey = key; + duplicateChatCache.remove(key); + } + + Duplicate dup = duplicateChatCache.get(key); + if (dup == null) + { + dup = new Duplicate(); + dup.count = 1; + dup.messageId = event.getMessageNode() != null ? event.getMessageNode().getId() : -1; + duplicateChatCache.put(key, dup); + } + else + { + dup.count++; + dup.messageId = event.getMessageNode() != null ? event.getMessageNode().getId() : -1; + } + } + + /** + * Intercepts chat line rendering to filter spam, collapse duplicates, and highlight names. + */ + @Subscribe + public void onScriptCallbackEvent(ScriptCallbackEvent event) + { + if (!"chatFilterCheck".equals(event.getEventName())) + { + return; + } + + if (!config.chatHelper()) + { + return; + } + + int[] intStack = client.getIntStack(); + int intStackSize = client.getIntStackSize(); + Object[] objectStack = client.getObjectStack(); + int objectStackSize = client.getObjectStackSize(); + + if (intStackSize < 3 || objectStackSize < 1) + { + return; + } + + int messageTypeVal = intStack[intStackSize - 2]; + int messageId = intStack[intStackSize - 1]; + String message = (String) objectStack[objectStackSize - 1]; + if (message == null) + { + return; + } + + // 1. Spam & Noise filtering + if (config.chatFilterSpam() && isSpamOrNoise(messageTypeVal, message)) + { + intStack[intStackSize - 3] = 0; // hide message + return; + } + + // 2. Duplicate collapsing + if (config.chatCollapseDuplicates()) + { + ChatMessageType type = ChatMessageType.of(messageTypeVal); + if (isCollapsible(type)) + { + MessageNode node = null; + if (client.getMessages() != null) + { + node = (MessageNode) client.getMessages().get((long) messageId); + } + String name = node != null && node.getName() != null ? node.getName() : ""; + + String key = name + ":" + (type != null ? type.ordinal() : messageTypeVal) + ":" + message; + Duplicate dup = duplicateChatCache.get(key); + if (dup != null && dup.messageId != -1) + { + if (dup.messageId == messageId) + { + if (dup.count > 1) + { + message = message + " (x" + dup.count + ")"; + objectStack[objectStackSize - 1] = message; + } + } + else + { + // Hide earlier duplicate instances + intStack[intStackSize - 3] = 0; + return; + } + } + } + } + + // 3. Highlight own name + if (config.chatHighlightName() && isPlayerSpeechType(messageTypeVal)) + { + String highlighted = highlightPlayerName(message); + if (highlighted != null) + { + objectStack[objectStackSize - 1] = highlighted; + } + } + } + + private boolean isCollapsible(ChatMessageType type) + { + if (type == null) + { + return false; + } + + switch (type) + { + case GAMEMESSAGE: + case ENGINE: + case SPAM: + case PUBLICCHAT: + case MODCHAT: + case AUTOTYPER: + case CLAN_CHAT: + case FRIENDSCHAT: + case ITEM_EXAMINE: + case OBJECT_EXAMINE: + case NPC_EXAMINE: + return true; + default: + return false; + } + } + + private boolean isPlayerSpeechType(int messageTypeVal) + { + ChatMessageType type = ChatMessageType.of(messageTypeVal); + if (type == null) + { + return false; + } + + switch (type) + { + case PUBLICCHAT: + case MODCHAT: + case FRIENDSCHAT: + case CLAN_CHAT: + case CLAN_GUEST_CHAT: + case CLAN_GIM_CHAT: + return true; + default: + return false; + } + } + + private boolean isSpamOrNoise(int messageTypeVal, String message) + { + ChatMessageType type = ChatMessageType.of(messageTypeVal); + if (type == null || message == null) + { + return false; + } + + String clean = Text.removeTags(message).trim(); + + if (type == ChatMessageType.GAMEMESSAGE || type == ChatMessageType.ENGINE || type == ChatMessageType.SPAM) + { + if (NOISE_MESSAGES.contains(clean) || NOISE_PREFIX_PATTERN.matcher(clean).matches()) + { + return true; + } + } + + if (type == ChatMessageType.PUBLICCHAT || type == ChatMessageType.MODCHAT || type == ChatMessageType.AUTOTYPER) + { + if (PUBLIC_SPAM_PATTERN.matcher(clean).matches()) + { + return true; + } + } + + return false; + } + + private String highlightPlayerName(String message) + { + Player local = client.getLocalPlayer(); + if (local == null) + { + return null; + } + + String name = local.getName(); + if (name == null || name.isEmpty()) + { + return null; + } + + String lower = message.toLowerCase(); + String lowerName = name.toLowerCase(); + if (!lower.contains(lowerName)) + { + return null; + } + + Color highlightColor = config.chatHighlightColor(); + String hex = ColorUtil.colorToHexCode(highlightColor != null ? highlightColor : new Color(0xFFD700)); + + Pattern pattern = Pattern.compile("(?i)(?" + name + ""); + } + + /** + * Adds "Copy message" and "Clear chat" to right-click menus. + */ + @Subscribe + public void onMenuEntryAdded(MenuEntryAdded event) + { + if (!config.chatHelper()) + { + return; + } + + int type = event.getType(); + if (type != MenuAction.CC_OP.getId() && type != MenuAction.CC_OP_LOW_PRIORITY.getId()) + { + return; + } + + int param1 = event.getActionParam1(); + int interfaceId = WidgetUtil.componentToInterface(param1); + int childId = WidgetUtil.componentToId(param1); + + if (interfaceId != InterfaceID.CHATBOX) + { + return; + } + + // 1. Copy message + if (config.chatCopyToClipboard() && "Report".equals(event.getOption())) + { + Widget lineWidget = client.getWidget(interfaceId, childId); + if (lineWidget != null && lineWidget.getParent() != null && lineWidget.getParent().getId() == ComponentID.CHATBOX_MESSAGE_LINES) + { + int lineIndex = (childId - WidgetUtil.componentToId(ComponentID.CHATBOX_FRAME)) * 4 + 1; + Widget textWidget = lineWidget.getParent().getChild(lineIndex); + if (textWidget != null && textWidget.getText() != null) + { + String fullText = textWidget.getText(); + client.createMenuEntry(1) + .setOption("Copy message") + .setTarget(event.getTarget()) + .setType(MenuAction.RUNELITE) + .onClick(e -> copyToClipboard(fullText)); + } + } + } + + // 2. Clear chat on chat tab buttons + String option = event.getOption(); + if (option != null && (option.startsWith("Switch tab") || option.startsWith("Filter"))) + { + client.createMenuEntry(-2) + .setOption("Clear chat") + .setType(MenuAction.RUNELITE_HIGH_PRIORITY) + .onClick(e -> clearAllChat()); + } + } + + private void copyToClipboard(String text) + { + try + { + String cleanText = Text.removeTags(text); + StringSelection selection = new StringSelection(cleanText); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, null); + } + catch (Exception e) + { + log.warn("Failed to copy chat text to clipboard", e); + } + } + + /** + * Manually empties all chat line buffers and rebuilds the chatbox widget. + */ + public void clearAllChat() + { + clientThread.invokeLater(() -> + { + Map chatLineMap = client.getChatLineMap(); + if (chatLineMap == null) + { + return; + } + + for (ChatLineBuffer buffer : chatLineMap.values()) + { + if (buffer == null) + { + continue; + } + + MessageNode[] lines = buffer.getLines(); + if (lines == null || lines.length == 0) + { + continue; + } + + MessageNode[] copy = lines.clone(); + for (MessageNode node : copy) + { + if (node != null) + { + buffer.removeMessageNode(node); + } + } + } + + client.runScript(83); + }); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java index 2e397c8..a428705 100644 --- a/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java +++ b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java @@ -1320,6 +1320,14 @@ public class ItemCharges } } + /** Tracked amount for a key of {@link #cachedCounts} (e.g. "coal_bag"), 0 while unknown. */ + public int getTrackedCount(String key) + { + loadAccountDataIfNecessary(); + Integer val = cachedCounts.get(key); + return val == null ? 0 : Math.max(0, val); + } + public void decrementCount(String key, int amount) { loadAccountDataIfNecessary(); diff --git a/src/main/java/com/example/essentialsplus/ui/HelpersCard.java b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java index 739ff73..52bcedc 100644 --- a/src/main/java/com/example/essentialsplus/ui/HelpersCard.java +++ b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java @@ -1,6 +1,7 @@ package com.example.essentialsplus.ui; import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.helpers.BlastFurnaceOverlay; import com.example.essentialsplus.helpers.TimerTextSize; import java.awt.Color; import java.awt.Dimension; @@ -19,6 +20,7 @@ import javax.swing.JLabel; import javax.swing.JPanel; import net.runelite.client.ui.FontManager; import net.runelite.client.ui.PluginPanel; +import net.runelite.client.ui.overlay.OverlayPosition; /** Overview card with on/off switches for the small in-game helpers (attack timers, ...). */ public class HelpersCard extends JPanel @@ -30,6 +32,8 @@ public class HelpersCard extends JPanel private final JCheckBox enemyAttackTimer = box("Enemy attack timer", "Ticks until attacking NPCs hit next, above their heads. Right-click for options"); private final JCheckBox treeCooldowns = box("Tree cooldowns", "Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen"); private final JCheckBox itemCharges = box("Item charges", "Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"); + private final JCheckBox blastFurnace = box("Blast Furnace", "Highlights conveyor belt, bar dispenser & bank chest, shows coal in furnace, coffer time, and bars craftable from bank ores. Right-click for options"); + private final JCheckBox chatHelper = box("Chat helper", "Auto-Clear nach 1 Min, Duplikate (x2, x3), Spam-Filter, Namen-Highlight & Kopieren. Rechtsklick für Optionen"); public HelpersCard(EssentialsPlusPlugin plugin) { @@ -102,6 +106,32 @@ public class HelpersCard extends JPanel } }); add(itemCharges); + blastFurnace.addActionListener(e -> plugin.setHelperOption("blastFurnace", blastFurnace.isSelected())); + blastFurnace.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + blastFurnaceMenu().show(blastFurnace, e.getX(), e.getY()); + } + } + }); + add(blastFurnace); + chatHelper.addActionListener(e -> plugin.setHelperOption("chatHelper", chatHelper.isSelected())); + chatHelper.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + chatMenu().show(chatHelper, e.getX(), e.getY()); + } + } + }); + add(chatHelper); refresh(); } @@ -114,8 +144,11 @@ public class HelpersCard extends JPanel itemPrices.setSelected(plugin.isHelperOption("itemPrices")); treeCooldowns.setSelected(plugin.isHelperOption("treeCooldowns")); itemCharges.setSelected(plugin.isHelperOption("itemCharges")); + blastFurnace.setSelected(plugin.isHelperOption("blastFurnace")); + chatHelper.setSelected(plugin.isHelperOption("chatHelper")); } + private static final Color READY = new Color(0x66FF66); private static final int[] HEIGHTS = {10, 30, 60, 100}; private static final String[] HEIGHT_LABELS = {"Low", "Normal", "High", "Very high"}; @@ -369,6 +402,187 @@ public class HelpersCard extends JPanel return item; } + private static final int[] BF_BANK_LINES = {1, 2, 3, 5}; + private static final OverlayPosition[] BF_POSITIONS = { + OverlayPosition.TOP_LEFT, OverlayPosition.TOP_RIGHT, OverlayPosition.BOTTOM_LEFT, OverlayPosition.BOTTOM_RIGHT + }; + private static final String[] BF_POSITION_LABELS = {"Top left", "Top right", "Bottom left", "Bottom right"}; + + /** Right-click on the Blast Furnace switch: what gets marked and which numbers the panel shows. */ + private JPopupMenu blastFurnaceMenu() + { + JPopupMenu menu = new JPopupMenu(); + + for (String[] opt : new String[][]{ + {"blastFurnaceMarkBelt", "Highlight conveyor belt"}, + {"blastFurnaceMarkDispenser", "Highlight bar dispenser"}, + {"blastFurnaceMarkBank", "Highlight bank chest"}, + {"blastFurnaceMarkBankItems", "Highlight ores in bank"}, + {"blastFurnaceMarkItems", "Highlight ores & bars in inventory"}, + {"blastFurnaceStamina", "Drink stamina potion at <= 30 energy"}, + {"blastFurnaceLabels", "Object labels"} + }) + { + menu.add(toggle(opt[0], opt[1])); + } + + menu.addSeparator(); + + menu.add(toggle("blastFurnacePanel", "Show info panel")); + + JMenu lines = new JMenu("Info panel: Rows"); + for (String[] opt : new String[][]{ + {"blastFurnaceShowCoal", "Coal in furnace"}, + {"blastFurnaceShowFurnaceBars", "Bars in furnace"}, + {"blastFurnaceShowCoffer", "Coffer"}, + {"blastFurnaceShowCofferTime", "Coffer time"}, + {"blastFurnaceShowBankBars", "Bars from bank ores"} + }) + { + lines.add(toggle(opt[0], opt[1])); + } + JMenu bankLines = new JMenu("Bank rows"); + int currentLines = plugin.getHelperValue("blastFurnaceBankLines", Integer.class, 3); + for (int value : BF_BANK_LINES) + { + bankLines.add(radio(String.valueOf(value), value == currentLines, () -> plugin.setHelperOption("blastFurnaceBankLines", value))); + } + lines.add(bankLines); + menu.add(lines); + + JMenu position = new JMenu("Info panel: Position"); + OverlayPosition currentPosition = plugin.getHelperValue("blastFurnacePosition", OverlayPosition.class, OverlayPosition.TOP_LEFT); + for (int i = 0; i < BF_POSITIONS.length; i++) + { + OverlayPosition value = BF_POSITIONS[i]; + position.add(radio(BF_POSITION_LABELS[i], value == currentPosition, () -> plugin.setHelperOption("blastFurnacePosition", value))); + } + menu.add(position); + + menu.addSeparator(); + + JMenu size = new JMenu("Label text size"); + TimerTextSize currentSize = plugin.getHelperValue("blastFurnaceTextSize", TimerTextSize.class, TimerTextSize.SMALL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("blastFurnaceTextSize", s))); + } + menu.add(size); + + menu.add(colorItem("Color...", "blastFurnaceColor", "Blast Furnace highlight color", BlastFurnaceOverlay.DEFAULT_MARK_COLOR)); + menu.add(colorItem("Ready color...", "blastFurnaceReadyColor", "Blast Furnace ready color", BlastFurnaceOverlay.DEFAULT_READY_COLOR)); + + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset appearance"); + reset.addActionListener(e -> + { + for (String key : new String[]{ + "blastFurnaceMarkBelt", "blastFurnaceMarkDispenser", "blastFurnaceMarkBank", "blastFurnaceMarkBankItems", + "blastFurnaceMarkItems", "blastFurnaceStamina", "blastFurnaceLabels", "blastFurnacePanel", "blastFurnaceShowCoal", + "blastFurnaceShowFurnaceBars", "blastFurnaceShowCoffer", "blastFurnaceShowCofferTime", + "blastFurnaceShowBankBars", "blastFurnaceBankLines", "blastFurnacePosition", "blastFurnaceTextSize", + "blastFurnaceColor", "blastFurnaceReadyColor"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static final int[] CHAT_CLEAR_TIMES = {0, 30, 60, 120, 300}; + private static final String[] CHAT_CLEAR_TIME_LABELS = {"Aus (0s)", "30 Sekunden", "1 Minute (Standard)", "2 Minuten", "5 Minuten"}; + + private JPopupMenu chatMenu() + { + JPopupMenu menu = new JPopupMenu(); + + boolean autoClear = plugin.getHelperValue("chatAutoClear", Boolean.class, true); + JCheckBoxMenuItem autoClearItem = new JCheckBoxMenuItem("Automatisch aufräumen", autoClear); + autoClearItem.addActionListener(e -> plugin.setHelperOption("chatAutoClear", !autoClear)); + menu.add(autoClearItem); + + JMenu clearTimeMenu = new JMenu("Auto-Clear Zeit"); + int currentTime = plugin.getHelperValue("chatAutoClearTime", Integer.class, 60); + for (int i = 0; i < CHAT_CLEAR_TIMES.length; i++) + { + int time = CHAT_CLEAR_TIMES[i]; + clearTimeMenu.add(radio(CHAT_CLEAR_TIME_LABELS[i], time == currentTime, () -> plugin.setHelperOption("chatAutoClearTime", time))); + } + menu.add(clearTimeMenu); + + JMenu typesMenu = new JMenu("Was wird aufgeräumt?"); + for (String[] opt : new String[][]{ + {"chatClearGame", "Spiel- & System-Meldungen", "true"}, + {"chatClearPublic", "Öffentlicher Chat (Public)", "true"}, + {"chatClearPrivate", "Private Nachrichten (PM)", "false"}, + {"chatClearClan", "Clan- & Freunde-Chat", "false"} + }) + { + boolean def = Boolean.parseBoolean(opt[2]); + boolean on = plugin.getHelperValue(opt[0], Boolean.class, def); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], !on)); + typesMenu.add(item); + } + menu.add(typesMenu); + + menu.addSeparator(); + + for (String[] opt : new String[][]{ + {"chatCollapseDuplicates", "Duplikate zusammenfassen (x2, x3...)"}, + {"chatFilterSpam", "Spam & Systemlärm filtern"}, + {"chatHighlightName", "Eigenen Namen hervorheben"}, + {"chatCopyToClipboard", "Rechtsklick 'Kopieren' aktivieren"} + }) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, true); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], !on)); + menu.add(item); + } + + menu.add(colorItem("Highlight-Farbe...", "chatHighlightColor", "Chat Highlight-Farbe", new Color(0xFFD700))); + + menu.addSeparator(); + + JMenuItem clearNow = new JMenuItem("Chat jetzt leeren"); + clearNow.addActionListener(e -> + { + if (plugin.getChatHelper() != null) + { + plugin.getChatHelper().clearAllChat(); + } + }); + menu.add(clearNow); + + JMenuItem reset = new JMenuItem("Einstellungen zurücksetzen"); + reset.addActionListener(e -> + { + for (String key : new String[]{ + "chatAutoClear", "chatAutoClearTime", "chatClearGame", "chatClearPublic", + "chatClearPrivate", "chatClearClan", "chatCollapseDuplicates", + "chatFilterSpam", "chatHighlightName", "chatHighlightColor", "chatCopyToClipboard" + }) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + + return menu; + } + + + /** Checkbox for a switch that is on by default (turning it on stores the default explicitly). */ + private JCheckBoxMenuItem toggle(String key, String label) + { + boolean on = plugin.getHelperValue(key, Boolean.class, true); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(label, on); + item.addActionListener(e -> plugin.setHelperOption(key, !on)); + return item; + } + private JMenuItem colorItem(String label, String key, String title, Color def) { JMenuItem item = new JMenuItem(label); diff --git a/src/test/java/com/example/essentialsplus/helpers/BlastFurnaceTest.java b/src/test/java/com/example/essentialsplus/helpers/BlastFurnaceTest.java new file mode 100644 index 0000000..e4196e8 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/helpers/BlastFurnaceTest.java @@ -0,0 +1,239 @@ +package com.example.essentialsplus.helpers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.gameval.ItemID; +import org.junit.Test; + +public class BlastFurnaceTest +{ + private static Map ores(Object... pairs) + { + Map map = new HashMap<>(); + for (int i = 0; i + 1 < pairs.length; i += 2) + { + map.put((Integer) pairs[i], (Integer) pairs[i + 1]); + } + return map; + } + + private static BlastFurnace.Estimate estimate(BlastFurnace.BarType type, int coal, Object... pairs) + { + return BlastFurnace.estimate(type, ores(pairs), coal); + } + + @Test + public void testCoalCostsAreTheBlastFurnaceOnes() + { + // the Blast Furnace needs half the coal of a normal furnace + assertEquals(0, BlastFurnace.BarType.IRON.getCoalPerBar()); + assertEquals(1, BlastFurnace.BarType.STEEL.getCoalPerBar()); + assertEquals(2, BlastFurnace.BarType.MITHRIL.getCoalPerBar()); + assertEquals(3, BlastFurnace.BarType.ADAMANTITE.getCoalPerBar()); + assertEquals(4, BlastFurnace.BarType.RUNITE.getCoalPerBar()); + assertEquals(0, BlastFurnace.BarType.GOLD.getCoalPerBar()); + } + + @Test + public void testSteelBarsFromIronAndCoal() + { + BlastFurnace.Estimate e = estimate(BlastFurnace.BarType.STEEL, 5_000, ItemID.IRON_ORE, 1_000, ItemID.COAL, 5_000); + + assertEquals(1_000, e.bars); + assertFalse(e.coalLimited); + } + + @Test + public void testSteelBarsAreLimitedByCoal() + { + BlastFurnace.Estimate e = estimate(BlastFurnace.BarType.STEEL, 250, ItemID.IRON_ORE, 1_000); + + assertEquals(250, e.bars); + assertTrue(e.coalLimited); + } + + @Test + public void testIronBarsNeedNoCoal() + { + BlastFurnace.Estimate e = estimate(BlastFurnace.BarType.IRON, 0, ItemID.IRON_ORE, 742); + + assertEquals(742, e.bars); + assertFalse(e.coalLimited); + } + + @Test + public void testMithrilBarsUseTwoCoalPerBar() + { + assertEquals(75, estimate(BlastFurnace.BarType.MITHRIL, 150, ItemID.MITHRIL_ORE, 100).bars); + assertEquals(100, estimate(BlastFurnace.BarType.MITHRIL, 200, ItemID.MITHRIL_ORE, 100).bars); + assertTrue(estimate(BlastFurnace.BarType.MITHRIL, 150, ItemID.MITHRIL_ORE, 100).coalLimited); + } + + @Test + public void testMissingOreMeansNoBars() + { + assertEquals(0, estimate(BlastFurnace.BarType.RUNITE, 10_000, ItemID.ADAMANTITE_ORE, 500).bars); + assertEquals(0, estimate(BlastFurnace.BarType.BRONZE, 10_000, ItemID.COPPER_ORE, 100).bars); + } + + @Test + public void testOresNeedingMoreThanOneItem() + { + // bronze: 1 copper + 1 tin + assertEquals(4, estimate(BlastFurnace.BarType.BRONZE, 0, ItemID.COPPER_ORE, 10, ItemID.TIN_ORE, 4).bars); + + // lead: 2 lead ore per bar + assertEquals(4, estimate(BlastFurnace.BarType.LEAD, 0, ItemID.LEAD_ORE, 9).bars); + + // cupronickel: 2 copper + 1 nickel per bar + assertEquals(3, estimate(BlastFurnace.BarType.CUPRONICKEL, 0, ItemID.COPPER_ORE, 10, ItemID.NICKEL_ORE, 3).bars); + assertEquals(5, estimate(BlastFurnace.BarType.CUPRONICKEL, 0, ItemID.COPPER_ORE, 10, ItemID.NICKEL_ORE, 9).bars); + } + + @Test + public void testNegativeAmountsAreClamped() + { + assertEquals(0, estimate(BlastFurnace.BarType.STEEL, -20, ItemID.IRON_ORE, 100).bars); + assertEquals(0, estimate(BlastFurnace.BarType.STEEL, 100, ItemID.IRON_ORE, -5).bars); + } + + @Test + public void testCofferTimeIsFiftyMillisecondsPerGp() + { + assertEquals(3_600_000L, BlastFurnace.cofferMillis(72_000)); + assertEquals(0L, BlastFurnace.cofferMillis(0)); + assertEquals(0L, BlastFurnace.cofferMillis(-1_000)); + } + + @Test + public void testOreAndBarLookup() + { + assertTrue(BlastFurnace.isOre(ItemID.RUNITE_ORE)); + assertTrue(BlastFurnace.isOre(ItemID.COAL)); + assertFalse(BlastFurnace.isOre(ItemID.RUNITE_BAR)); + + assertTrue(BlastFurnace.isBar(ItemID.RUNITE_BAR)); + assertFalse(BlastFurnace.isBar(ItemID.RUNITE_ORE)); + assertEquals(BlastFurnace.BarType.RUNITE, BlastFurnace.barTypeOf(ItemID.RUNITE_BAR)); + } + + @Test + public void testRequiredFurnaceCoalFormula() + { + // Runite: 4 coal per bar. With coal bag + buffer: 4x ore slots = 4 * 27 = 108 + assertEquals(108, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.RUNITE, 27, true)); + // Runite without coal bag: 5 * 27 = 135 + assertEquals(135, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.RUNITE, 27, false)); + + // Adamantite: 3 coal per bar. With coal bag + buffer: 3 * 27 = 81 + assertEquals(81, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.ADAMANTITE, 27, true)); + assertEquals(108, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.ADAMANTITE, 27, false)); + + // Mithril: 2 coal per bar. With coal bag + buffer: 2 * 27 = 54 + assertEquals(54, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.MITHRIL, 27, true)); + + // Steel: 1 coal per bar. With coal bag + buffer: 1 * 27 = 27 + assertEquals(27, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.STEEL, 27, true)); + assertEquals(54, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.STEEL, 27, false)); + + // Gold: 0 coal + assertEquals(0, BlastFurnace.getRequiredFurnaceCoal(BlastFurnace.BarType.GOLD, 27, true)); + } + + @Test + public void testTargetBankOrePriorityRuniteFirstThenAdamantite() + { + Map bank = new HashMap<>(); + bank.put(ItemID.RUNITE_ORE, 100); + bank.put(ItemID.ADAMANTITE_ORE, 500); + bank.put(ItemID.MITHRIL_ORE, 1000); + bank.put(ItemID.COAL, 5000); + + // With 99 Smithing, coal bag, and 120 coal in furnace (>= 108) -> Runite Ore + assertEquals(ItemID.RUNITE_ORE, BlastFurnace.pickTargetBankOre(99, 120, 27, true, id -> bank.getOrDefault(id, 0))); + + // If furnace has only 80 coal (< 108 needed for Runite) -> Coal! + assertEquals(ItemID.COAL, BlastFurnace.pickTargetBankOre(99, 80, 27, true, id -> bank.getOrDefault(id, 0))); + + // If no Runite present, but 90+ coal in furnace (>= 81 for Adamantite) -> Adamantite + bank.remove(ItemID.RUNITE_ORE); + assertEquals(ItemID.ADAMANTITE_ORE, BlastFurnace.pickTargetBankOre(99, 90, 27, true, id -> bank.getOrDefault(id, 0))); + + // If no Runite present, and only 60 coal in furnace (< 81 for Adamantite) -> Coal! + assertEquals(ItemID.COAL, BlastFurnace.pickTargetBankOre(99, 60, 27, true, id -> bank.getOrDefault(id, 0))); + } + + @Test + public void testTargetBankOreRespectsSmithingLevel() + { + Map bank = new HashMap<>(); + bank.put(ItemID.RUNITE_ORE, 100); + bank.put(ItemID.ADAMANTITE_ORE, 500); + bank.put(ItemID.MITHRIL_ORE, 1000); + bank.put(ItemID.COAL, 5000); + + // At level 75 (cannot smelt Runite 85), should pick Adamantite when furnace has 90 coal (>= 81) + assertEquals(ItemID.ADAMANTITE_ORE, BlastFurnace.pickTargetBankOre(75, 90, 27, true, id -> bank.getOrDefault(id, 0))); + + // At level 55 (cannot smelt Adamantite 70), should pick Mithril when furnace has 60 coal (>= 54) + assertEquals(ItemID.MITHRIL_ORE, BlastFurnace.pickTargetBankOre(55, 60, 27, true, id -> bank.getOrDefault(id, 0))); + } + + @Test + public void testStaminaAndVialItemIds() + { + assertTrue(ItemID.VIAL_EMPTY > 0); + assertTrue(ItemID._1DOSESTAMINA > 0); + assertTrue(ItemID._2DOSESTAMINA > 0); + assertTrue(ItemID._3DOSESTAMINA > 0); + assertTrue(ItemID._4DOSESTAMINA > 0); + assertTrue(ItemID._1DOSE2STAMINA > 0); + assertTrue(ItemID._2DOSE2STAMINA > 0); + assertTrue(ItemID._3DOSE2STAMINA > 0); + assertTrue(ItemID._4DOSE2STAMINA > 0); + } + + @Test + public void testStaminaPotionRankingPrefersOneDoseAndExtended() + { + // 1-dose extended is #1 + assertEquals(ItemID._1DOSE2STAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[0]); + // 1-dose normal is #2 (1-dose prioritized) + assertEquals(ItemID._1DOSESTAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[1]); + // Extended 2-4 before normal 2-4 + assertEquals(ItemID._2DOSE2STAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[2]); + assertEquals(ItemID._3DOSE2STAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[3]); + assertEquals(ItemID._4DOSE2STAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[4]); + assertEquals(ItemID._2DOSESTAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[5]); + assertEquals(ItemID._3DOSESTAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[6]); + assertEquals(ItemID._4DOSESTAMINA, BlastFurnace.STAMINA_POTION_PRIORITY[7]); + } + + @Test + public void testMinimumOreBatchOf27() + { + Map bank = new HashMap<>(); + bank.put(ItemID.RUNITE_ORE, 26); // Less than 27 -> not used! + bank.put(ItemID.ADAMANTITE_ORE, 50); // >= 27 -> used! + bank.put(ItemID.COAL, 5000); + + // With 26 Runite ore, Runite is skipped, falling back to Adamantite + assertEquals(BlastFurnace.BarType.ADAMANTITE, BlastFurnace.pickTargetBarType(99, id -> bank.getOrDefault(id, 0))); + assertEquals(ItemID.ADAMANTITE_ORE, BlastFurnace.pickTargetBankOre(99, 90, 27, true, id -> bank.getOrDefault(id, 0))); + + // If Runite has exactly 27, it IS used + bank.put(ItemID.RUNITE_ORE, 27); + assertEquals(BlastFurnace.BarType.RUNITE, BlastFurnace.pickTargetBarType(99, id -> bank.getOrDefault(id, 0))); + assertEquals(ItemID.RUNITE_ORE, BlastFurnace.pickTargetBankOre(99, 120, 27, true, id -> bank.getOrDefault(id, 0))); + + // If all ores have < 27, finished! (null / -1) + bank.put(ItemID.RUNITE_ORE, 26); + bank.put(ItemID.ADAMANTITE_ORE, 15); + bank.put(ItemID.MITHRIL_ORE, 20); + org.junit.Assert.assertNull(BlastFurnace.pickTargetBarType(99, id -> bank.getOrDefault(id, 0))); + assertEquals(-1, BlastFurnace.pickTargetBankOre(99, 120, 27, true, id -> bank.getOrDefault(id, 0))); + } +} diff --git a/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java b/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java new file mode 100644 index 0000000..68530b7 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java @@ -0,0 +1,307 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import java.awt.Color; +import java.lang.reflect.Proxy; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.runelite.api.ChatLineBuffer; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.IterableHashTable; +import net.runelite.api.MessageNode; +import net.runelite.api.Player; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.ScriptCallbackEvent; +import net.runelite.client.callback.ClientThread; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class ChatHelperTest +{ + private Client client; + private EssentialsPlusConfig config; + private ClientThread clientThread; + private ChatHelper chatHelper; + + private final Map chatLineMap = new HashMap<>(); + private final List scriptsRun = new ArrayList<>(); + private final Map configValues = new HashMap<>(); + private final Map messagesMap = new HashMap<>(); + private int[] intStack; + private Object[] objStack; + private Player localPlayer; + + @SuppressWarnings("unchecked") + private static T mockInterface(Class iface, java.lang.reflect.InvocationHandler handler) + { + return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class[]{iface}, handler); + } + + private MessageNode createMessageNode(int id, ChatMessageType type, String name, String value, int timestamp) + { + final String[] valRef = new String[]{value}; + final int[] tsRef = new int[]{timestamp}; + + return mockInterface(MessageNode.class, (proxy, method, args) -> + { + switch (method.getName()) + { + case "getId": + return id; + case "getType": + return type; + case "getName": + return name; + case "getValue": + return valRef[0]; + case "setValue": + valRef[0] = (String) args[0]; + return null; + case "getTimestamp": + return tsRef[0]; + case "setTimestamp": + tsRef[0] = (Integer) args[0]; + return null; + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + default: + return null; + } + }); + } + + private ChatLineBuffer createChatLineBuffer(List initialLines) + { + List list = new ArrayList<>(initialLines); + return mockInterface(ChatLineBuffer.class, (proxy, method, args) -> + { + switch (method.getName()) + { + case "getLines": + return list.toArray(new MessageNode[0]); + case "getLength": + return list.size(); + case "removeMessageNode": + list.remove(args[0]); + return null; + default: + return null; + } + }); + } + + @Before + public void setup() + { + chatLineMap.clear(); + scriptsRun.clear(); + configValues.clear(); + messagesMap.clear(); + + // Default config values + configValues.put("chatHelper", true); + configValues.put("chatAutoClear", true); + configValues.put("chatAutoClearTime", 60); + configValues.put("chatClearGame", true); + configValues.put("chatClearPublic", true); + configValues.put("chatClearPrivate", false); + configValues.put("chatClearClan", false); + configValues.put("chatCollapseDuplicates", true); + configValues.put("chatFilterSpam", true); + configValues.put("chatHighlightName", true); + configValues.put("chatHighlightColor", new Color(0xFFD700)); + configValues.put("chatCopyToClipboard", true); + + config = mockInterface(EssentialsPlusConfig.class, (proxy, method, args) -> + { + if (configValues.containsKey(method.getName())) + { + return configValues.get(method.getName()); + } + return null; + }); + + localPlayer = mockInterface(Player.class, (proxy, method, args) -> + { + if ("getName".equals(method.getName())) + { + return "Zezima"; + } + return null; + }); + + clientThread = new ClientThread() + { + @Override + public void invoke(Runnable r) + { + r.run(); + } + + @Override + public void invokeLater(Runnable r) + { + r.run(); + } + }; + + client = mockInterface(Client.class, (proxy, method, args) -> + { + switch (method.getName()) + { + case "getChatLineMap": + return chatLineMap; + case "runScript": + scriptsRun.add((Object[]) args); + return null; + case "getLocalPlayer": + return localPlayer; + case "getIntStack": + return intStack; + case "getIntStackSize": + return intStack != null ? intStack.length : 0; + case "getObjectStack": + return objStack; + case "getObjectStackSize": + return objStack != null ? objStack.length : 0; + case "getMessages": + return mockInterface(IterableHashTable.class, (p2, m2, a2) -> + { + if ("get".equals(m2.getName())) + { + return messagesMap.get((Long) a2[0]); + } + return null; + }); + default: + return null; + } + }); + + chatHelper = new ChatHelper(client, config, clientThread); + } + + @Test + public void testAutoClearOldMessages() + { + int now = (int) Instant.now().getEpochSecond(); + MessageNode oldNode = createMessageNode(1, ChatMessageType.GAMEMESSAGE, "", "You cut a willow log.", now - 65); + MessageNode recentNode = createMessageNode(2, ChatMessageType.GAMEMESSAGE, "", "You cut an oak log.", now - 10); + + List bufferList = new ArrayList<>(); + bufferList.add(oldNode); + bufferList.add(recentNode); + chatLineMap.put(0, createChatLineBuffer(bufferList)); + + chatHelper.onGameTick(new GameTick()); + + ChatLineBuffer buf = chatLineMap.get(0); + Assert.assertEquals(1, buf.getLines().length); + Assert.assertEquals("You cut an oak log.", buf.getLines()[0].getValue()); + Assert.assertFalse(scriptsRun.isEmpty()); + } + + @Test + public void testPrivateMessageProtection() + { + int now = (int) Instant.now().getEpochSecond(); + MessageNode pmNode = createMessageNode(1, ChatMessageType.PRIVATECHAT, "Friend", "Meet me at GE", now - 120); + + List bufferList = new ArrayList<>(); + bufferList.add(pmNode); + chatLineMap.put(1, createChatLineBuffer(bufferList)); + + chatHelper.onGameTick(new GameTick()); + + // Private messages should NOT be cleared because chatClearPrivate is false + ChatLineBuffer buf = chatLineMap.get(1); + Assert.assertEquals(1, buf.getLines().length); + Assert.assertEquals("Meet me at GE", buf.getLines()[0].getValue()); + } + + @Test + public void testSpamFilter() + { + intStack = new int[]{1, ChatMessageType.GAMEMESSAGE.getType(), 10}; + objStack = new Object[]{"Nothing interesting happens."}; + + ScriptCallbackEvent event = new ScriptCallbackEvent(); + event.setEventName("chatFilterCheck"); + chatHelper.onScriptCallbackEvent(event); + + // intStack[0] (display flag) should be set to 0 (hidden) + Assert.assertEquals(0, intStack[0]); + } + + @Test + public void testNameHighlight() + { + intStack = new int[]{1, ChatMessageType.PUBLICCHAT.getType(), 20}; + objStack = new Object[]{"Hey Zezima where are you going?"}; + + ScriptCallbackEvent event = new ScriptCallbackEvent(); + event.setEventName("chatFilterCheck"); + chatHelper.onScriptCallbackEvent(event); + + String result = (String) objStack[0]; + Assert.assertTrue(result.contains("Zezima")); + } + + @Test + public void testClearAllChat() + { + int now = (int) Instant.now().getEpochSecond(); + MessageNode node1 = createMessageNode(1, ChatMessageType.GAMEMESSAGE, "", "msg1", now); + MessageNode node2 = createMessageNode(2, ChatMessageType.PUBLICCHAT, "p1", "msg2", now); + + List list = new ArrayList<>(); + list.add(node1); + list.add(node2); + chatLineMap.put(0, createChatLineBuffer(list)); + + chatHelper.clearAllChat(); + + Assert.assertEquals(0, chatLineMap.get(0).getLines().length); + Assert.assertFalse(scriptsRun.isEmpty()); + } + + @Test + public void testDuplicateCollapsing() + { + MessageNode node1 = createMessageNode(101, ChatMessageType.GAMEMESSAGE, "", "You catch a trout.", 1000); + MessageNode node2 = createMessageNode(102, ChatMessageType.GAMEMESSAGE, "", "You catch a trout.", 1001); + messagesMap.put(101L, node1); + messagesMap.put(102L, node2); + + ChatMessage chatEvent1 = new ChatMessage(node1, ChatMessageType.GAMEMESSAGE, "", "You catch a trout.", "", 1000); + ChatMessage chatEvent2 = new ChatMessage(node2, ChatMessageType.GAMEMESSAGE, "", "You catch a trout.", "", 1001); + + chatHelper.onChatMessage(chatEvent1); + chatHelper.onChatMessage(chatEvent2); + + // Check earlier duplicate + intStack = new int[]{1, ChatMessageType.GAMEMESSAGE.getType(), 101}; + objStack = new Object[]{"You catch a trout."}; + ScriptCallbackEvent sc1 = new ScriptCallbackEvent(); + sc1.setEventName("chatFilterCheck"); + chatHelper.onScriptCallbackEvent(sc1); + Assert.assertEquals(0, intStack[0]); + + // Check latest duplicate + intStack = new int[]{1, ChatMessageType.GAMEMESSAGE.getType(), 102}; + objStack = new Object[]{"You catch a trout."}; + ScriptCallbackEvent sc2 = new ScriptCallbackEvent(); + sc2.setEventName("chatFilterCheck"); + chatHelper.onScriptCallbackEvent(sc2); + Assert.assertEquals(1, intStack[0]); + Assert.assertTrue(((String) objStack[0]).contains("(x2)")); + } +}