package com.example.essentialsplus; import com.example.essentialsplus.data.AccountData; import com.example.essentialsplus.data.ItemLedger; import com.example.essentialsplus.data.ItemSnapshot; import com.example.essentialsplus.data.Snapshot; import com.example.essentialsplus.data.SourceValue; import com.example.essentialsplus.data.TopItem; import com.example.essentialsplus.skills.SkillHistory; import com.example.essentialsplus.skills.SkillStore; import com.example.essentialsplus.skills.SkillsHudOverlay; import com.example.essentialsplus.ui.EssentialsPlusPanel; import com.example.essentialsplus.ui.TimeFilter; import com.example.essentialsplus.ui.WealthPanel; import com.example.essentialsplus.wiki.WikiApi; import com.example.essentialsplus.wiki.WikiOverlay; import com.example.essentialsplus.wiki.WikiWindow; import com.example.essentialsplus.wiki.WikiWindows; import com.google.inject.Provides; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; import net.runelite.client.util.ImageUtil; import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import javax.inject.Inject; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.EnumComposition; 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.events.GameStateChanged; import net.runelite.api.events.GameTick; import net.runelite.api.events.GrandExchangeOfferChanged; import net.runelite.api.events.ItemContainerChanged; import net.runelite.api.events.VarbitChanged; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.gameval.InventoryID; import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarbitID; import net.runelite.api.widgets.ComponentID; import java.util.Iterator; import net.runelite.client.callback.ClientThread; import net.runelite.client.config.ConfigManager; import net.runelite.client.config.RuneScapeProfileType; import net.runelite.client.eventbus.Subscribe; import net.runelite.client.events.ConfigChanged; import net.runelite.client.events.OverlayMenuClicked; import net.runelite.client.game.ItemManager; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.NavigationButton; import com.example.essentialsplus.data.GeBuyLimit; import com.example.essentialsplus.data.GeLimitInfo; import com.example.essentialsplus.data.GeSlot; import java.time.Duration; import java.util.Locale; import net.runelite.api.GrandExchangeOffer; import net.runelite.api.GrandExchangeOfferState; import net.runelite.api.VarPlayer; import net.runelite.api.Varbits; import net.runelite.api.events.ClientTick; import net.runelite.api.events.ScriptCallbackEvent; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.game.ItemStats; @Slf4j @PluginDescriptor( name = "Essentials Plus", description = "Moderne Plugin-Suite & Übersicht (Wealth Tracker, Net Worth, Bank, GE limits und mehr)", tags = {"essentials", "plus", "wiki", "wealth", "net worth", "bank", "gp", "graph", "coffer", "value", "history", "analysis"} ) public class EssentialsPlusPlugin extends Plugin { private static final int DOOM_REWARD_CONTAINER = 923; private static final int RUNEPOUCH_RUNE_ENUM = 982; private static final int[] RUNE_POUCH_TYPES = { VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, VarbitID.RUNE_POUCH_TYPE_4, VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6 }; private static final int[] RUNE_POUCH_QUANTITIES = { VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, VarbitID.RUNE_POUCH_QUANTITY_4, VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 }; /** Item containers the game only sends while/after they are opened; last contents are persisted. */ private static final ContainerSource[] CONTAINERS = { new ContainerSource(InventoryID.BANK, "bank", "Bank", WealthCategory.BANK), new ContainerSource(InventoryID.INV, "inventory", "Inventory", WealthCategory.INVENTORY), new ContainerSource(InventoryID.WORN, "equipment", "Equipment", WealthCategory.EQUIPMENT), new ContainerSource(InventoryID.LOOTING_BAG, "lootingBag", "Looting bag", WealthCategory.STORAGE), new ContainerSource(InventoryID.SEED_VAULT, "seedVault", "Seed vault", WealthCategory.STORAGE), }; private static final Set WATCHED_VARBITS = new java.util.HashSet<>(Arrays.asList( VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH, VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, VarbitID.RUNE_POUCH_TYPE_4, VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6, VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, VarbitID.RUNE_POUCH_QUANTITY_4, VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 )); /** Blast Furnace: ores inside the furnace and bars waiting in the dispenser (varbit, item id) */ private static final int[][] BLAST_FURNACE_ITEMS = { {VarbitID.BLAST_FURNACE_TIN_ORE, ItemID.TIN_ORE}, {VarbitID.BLAST_FURNACE_COPPER_ORE, ItemID.COPPER_ORE}, {VarbitID.BLAST_FURNACE_IRON_ORE, ItemID.IRON_ORE}, {VarbitID.BLAST_FURNACE_MITHRIL_ORE, ItemID.MITHRIL_ORE}, {VarbitID.BLAST_FURNACE_ADAMANTITE_ORE, ItemID.ADAMANTITE_ORE}, {VarbitID.BLAST_FURNACE_RUNITE_ORE, ItemID.RUNITE_ORE}, {VarbitID.BLAST_FURNACE_GOLD_ORE, ItemID.GOLD_ORE}, {VarbitID.BLAST_FURNACE_SILVER_ORE, ItemID.SILVER_ORE}, {VarbitID.BLAST_FURNACE_COAL, ItemID.COAL}, {VarbitID.BLAST_FURNACE_LEAD_ORE, ItemID.LEAD_ORE}, {VarbitID.BLAST_FURNACE_NICKEL_ORE, ItemID.NICKEL_ORE}, {VarbitID.BLAST_FURNACE_BRONZE_BARS, ItemID.BRONZE_BAR}, {VarbitID.BLAST_FURNACE_IRON_BARS, ItemID.IRON_BAR}, {VarbitID.BLAST_FURNACE_STEEL_BARS, ItemID.STEEL_BAR}, {VarbitID.BLAST_FURNACE_MITHRIL_BARS, ItemID.MITHRIL_BAR}, {VarbitID.BLAST_FURNACE_ADAMANTITE_BARS, ItemID.ADAMANTITE_BAR}, {VarbitID.BLAST_FURNACE_RUNITE_BARS, ItemID.RUNITE_BAR}, {VarbitID.BLAST_FURNACE_GOLD_BARS, ItemID.GOLD_BAR}, {VarbitID.BLAST_FURNACE_SILVER_BARS, ItemID.SILVER_BAR}, {VarbitID.BLAST_FURNACE_LEAD_BARS, ItemID.LEAD_BAR}, {VarbitID.BLAST_FURNACE_CUPRONICKEL_BARS, ItemID.CUPRONICKEL_BAR} }; private static final Set COFFER_VARBITS = Set.of(VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH); private static final Set BLAST_FURNACE_VARBITS = new java.util.HashSet<>(); private static final int BLAST_FURNACE_REGION = 7757; static { for (int[] bf : BLAST_FURNACE_ITEMS) { WATCHED_VARBITS.add(bf[0]); BLAST_FURNACE_VARBITS.add(bf[0]); } } /** quiet ticks before a normal change (inventory while skilling, GE collect) is valued */ private static final int COMPUTE_MIN_TICKS = 2; /** a burst of changes is valued after this many ticks even if it keeps changing */ private static final int COMPUTE_MAX_DELAY_TICKS = 5; /** periodic re-valuation (prices) when nothing happens */ private static final int COMPUTE_IDLE_TICKS = 100; private static final long SAVE_INTERVAL_MS = 60_000; private static final long LEDGER_SAVE_INTERVAL_MS = 5 * 60_000; private static final long HOUR_MS = 3_600_000; private static final long OFFLINE_GRACE_MS = 10 * 60_000; private static final int TOP_ITEMS = 60; @Inject private Client client; @Inject private ClientThread clientThread; @Inject private ClientToolbar clientToolbar; @Inject @Getter private ItemManager itemManager; @Inject private EssentialsPlusConfig config; @Inject @Getter private WealthStore store; @Inject @Getter private com.example.essentialsplus.loot.LootTrackerStore lootTrackerStore; @Inject @Getter private com.example.essentialsplus.loot.combat.CombatTracker combatTracker; @Inject private net.runelite.client.game.LootManager lootManager; @Inject @Getter private SkillStore skillStore; /** Level goals per skill, set from the skills tab. */ @Getter private com.example.essentialsplus.skills.SkillGoals skillGoals; @Inject private com.example.essentialsplus.currency.CurrencyStore currencyStore; /** Minigame points, marks of grace and other tokens of the logged-in character. */ @Getter private com.example.essentialsplus.currency.CurrencyTracker currencyTracker; @Inject @Getter private net.runelite.client.game.SkillIconManager skillIconManager; @Inject private net.runelite.client.ui.overlay.OverlayManager overlayManager; @Inject private SkillsHudOverlay skillsHudOverlay; @Inject private com.example.essentialsplus.helpers.AttackTimers attackTimers; @Inject private com.example.essentialsplus.helpers.AttackStyles attackStyles; @Inject private com.example.essentialsplus.helpers.AttackTimerOverlay attackTimerOverlay; @Inject private com.example.essentialsplus.helpers.ItemPriceOverlay itemPriceOverlay; @Inject private com.example.essentialsplus.helpers.TreeCooldowns treeCooldowns; @Inject private com.example.essentialsplus.helpers.TreeCooldownOverlay treeCooldownOverlay; @Inject private com.example.essentialsplus.helpers.ItemCharges itemCharges; @Inject private com.example.essentialsplus.helpers.ItemChargesOverlay itemChargesOverlay; @Inject private net.runelite.client.eventbus.EventBus eventBus; @Inject private net.runelite.client.ui.components.colorpicker.ColorPickerManager colorPickerManager; @Inject private okhttp3.OkHttpClient okHttpClient; @Inject private com.google.gson.Gson gson; @Inject private net.runelite.client.input.MouseManager mouseManager; @Inject @Getter private net.runelite.client.game.SpriteManager spriteManager; @Getter private WikiApi wikiApi; /** Worn gear, bank gear planner and the live target of the fights view. */ @Getter private com.example.essentialsplus.loot.gear.FightService fightService; /** Collection log pages and the items the logged-in account has obtained. */ @Getter private com.example.essentialsplus.collectionlog.CollectionLogService collectionLog; /** Achievement diary and combat achievement progress of the logged-in account. */ @Getter private com.example.essentialsplus.progress.ProgressService progress; /** Wiki page loads, searches and thumbnails; kept off the shared executor because image downloads can take seconds. */ @Getter private java.util.concurrent.ExecutorService wikiExecutor; private WikiWindows wikiWindows; private WikiOverlay wikiOverlay; /** Global time range of all views; persisted in the config. */ @Getter private TimeFilter timeFilter; /** Account key of the logged-in character for skill tracking (null when logged out). */ @Getter private volatile String skillKey; private boolean xpDirty; private ScheduledFuture skillSaveTask; private String pendingCrateName; private int pendingCrateTick; private Map invSnapshotBeforeOpen; @Inject private ScheduledExecutorService executor; @Inject private ConfigManager configManager; /** timestamp for a pinned item snapshot (session start), 0 = none */ private volatile long pinLedgerAt; /** true until the next LOGGED_IN is a fresh login (hops and reconnects keep the session) */ private boolean awaitingLogin = true; /** the real bank was not visible yet this session - the saved one may be outdated (played elsewhere, closed client) */ private boolean bankSeenThisSession; private EssentialsPlusPanel panel; private NavigationButton navButton; private ScheduledFuture reloadTask; private ScheduledFuture revalueTask; @Getter private volatile String activeKey; private boolean dirty = true; private int ticksSinceCompute; private int quietTicks; /** ticks since the oldest change that is not valued yet */ private int dirtyTicks; /** value on the next tick (bank opened/closed, gear changed) */ private boolean urgent; /** sources that only change on specific events are read then and cached in between */ private boolean pouchDirty = true; private boolean coffersDirty = true; private boolean furnaceDirty = true; private final long[] cofferCache = new long[3]; private Map furnaceCache = new HashMap<>(); private int ticksSinceLogin = -1; private boolean bankUpdated; private volatile boolean bankOpen; private volatile boolean justOpenedBank; private Map lastInvItems; /** * Container contents captured when they change. The bank container is gone once the bank is closed, so a * withdrawal right before closing would otherwise be valued with the old bank (items counted twice). */ private final Map> seenContainers = new java.util.concurrent.ConcurrentHashMap<>(); private Map lastWornItems; private final GrandExchangeTracker geTracker = new GrandExchangeTracker(); private long lastSave; private long lastLedgerSave; private boolean unsaved; private final Map lastRevalue = new HashMap<>(); @Provides EssentialsPlusConfig provideConfig(ConfigManager configManager) { return configManager.getConfig(EssentialsPlusConfig.class); } @Override protected void startUp() { timeFilter = new TimeFilter(TimeFilter.parse(configManager.getConfiguration(EssentialsPlusConfig.GROUP, "timeRange")), r -> configManager.setConfiguration(EssentialsPlusConfig.GROUP, "timeRange", r.name())); awaitingLogin = client.getGameState() != GameState.LOGGED_IN; if (!awaitingLogin) { pinLedgerAt = timeFilter.getSessionStart(); } skillStore.load(); skillGoals = new com.example.essentialsplus.skills.SkillGoals(configManager); overlayManager.add(skillsHudOverlay); eventBus.register(attackTimers); eventBus.register(attackStyles); eventBus.register(treeCooldowns); eventBus.register(itemCharges); overlayManager.add(attackTimerOverlay); overlayManager.add(itemPriceOverlay); overlayManager.add(treeCooldownOverlay); overlayManager.add(itemChargesOverlay); currencyStore.load(); currencyTracker = new com.example.essentialsplus.currency.CurrencyTracker(client, currencyStore); skillSaveTask = executor.scheduleWithFixedDelay(() -> { skillStore.saveDirty(); currencyStore.saveDirty(); }, 60, 60, TimeUnit.SECONDS); startWiki(); collectionLog = new com.example.essentialsplus.collectionlog.CollectionLogService(client, configManager); progress = new com.example.essentialsplus.progress.ProgressService(client, okHttpClient, wikiExecutor); 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; }); panel = new EssentialsPlusPanel(this); BufferedImage icon = loadIcon(); navButton = NavigationButton.builder() .tooltip("Essentials Plus") .icon(icon) .priority(7) .panel(panel) .build(); clientToolbar.addNavigation(navButton); executor.execute(() -> { store.reloadExternal(activeKey); refreshPanel(); }); reloadTask = executor.scheduleWithFixedDelay(() -> { if (store.reloadExternal(activeKey)) { refreshPanel(); } }, 30, 30, TimeUnit.SECONDS); revalueTask = executor.scheduleWithFixedDelay(() -> clientThread.invokeLater(this::revalueOffline), 20, 600, TimeUnit.SECONDS); lootTrackerStore.load(); lootTrackerStore.addChangeListener(this::refreshPanel); dirty = true; if (client.getGameState() == GameState.LOGGED_IN) { if (ticksSinceLogin < 0) { ticksSinceLogin = 0; } clientThread.invokeLater(this::syncGeOffers); } } @Override protected void shutDown() { stopWiki(); fightService = null; lootTrackerStore.save(); overlayManager.remove(skillsHudOverlay); overlayManager.remove(attackTimerOverlay); overlayManager.remove(itemPriceOverlay); overlayManager.remove(treeCooldownOverlay); overlayManager.remove(itemChargesOverlay); eventBus.unregister(attackTimers); eventBus.unregister(attackStyles); eventBus.unregister(treeCooldowns); eventBus.unregister(itemCharges); attackTimers.reset(); attackStyles.reset(); treeCooldowns.reset(); itemCharges.reset(); if (skillSaveTask != null) { skillSaveTask.cancel(false); skillSaveTask = null; } skillStore.saveDirty(); currencyStore.saveDirty(); skillKey = null; if (reloadTask != null) { reloadTask.cancel(false); reloadTask = null; } if (revalueTask != null) { revalueTask.cancel(false); revalueTask = null; } AccountData acc = store.get(activeKey); if (acc != null) { recordFinal(acc); WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), System.currentTimeMillis()); store.save(acc); ItemLedger ledger = store.ledger(acc.key, true); if (ledger.dirty) { store.saveLedger(acc.key, ledger); } } activeKey = null; bankOpen = false; lastInvItems = null; lastWornItems = null; clientToolbar.removeNavigation(navButton); if (panel != null) { panel.dispose(); } panel = null; navButton = null; } private void refreshPanel() { EssentialsPlusPanel p = panel; if (p != null) { p.refreshLater(); } } // ---------------------------------------------------------------- wiki private static final String WIKI_BOUNDS = "wikiBounds"; private static final String WIKI_RECENT = "wikiRecent"; private static final int WIKI_RECENT_MAX = 10; private void startWiki() { java.util.concurrent.atomic.AtomicInteger threads = new java.util.concurrent.atomic.AtomicInteger(); wikiExecutor = java.util.concurrent.Executors.newFixedThreadPool(3, r -> { Thread t = new Thread(r, "essentials-wiki-" + threads.incrementAndGet()); t.setDaemon(true); return t; }); wikiApi = new WikiApi(okHttpClient, gson); wikiWindows = new WikiWindows(wikiApi, wikiExecutor, client::getCanvasWidth, client::getCanvasHeight, loadWikiBounds(), this::saveWikiBounds, this::addWikiRecent); wikiOverlay = new WikiOverlay(spriteManager, wikiWindows); overlayManager.add(wikiOverlay); mouseManager.registerMouseListener(wikiWindows); mouseManager.registerMouseWheelListener(wikiWindows); } private void stopWiki() { if (wikiWindows != null) { mouseManager.unregisterMouseListener(wikiWindows); mouseManager.unregisterMouseWheelListener(wikiWindows); wikiWindows.closeAll(); wikiWindows = null; } if (wikiOverlay != null) { overlayManager.remove(wikiOverlay); wikiOverlay = null; } if (wikiExecutor != null) { wikiExecutor.shutdownNow(); wikiExecutor = null; } } /** Opens a wiki page in the front in-game wiki window (a new one if none is open). */ public void openWikiPage(String title) { WikiWindows w = wikiWindows; if (w != null) { w.open(title, null); } } /** Opens a wiki page in an additional in-game window. */ public void openWikiPageNewWindow(String title) { WikiWindows w = wikiWindows; if (w != null) { w.openNew(title, null); } } /** Layout and display options of the overview cards ("overview."), null when unset. */ public String getOverviewSetting(String name) { return configManager.getConfiguration(EssentialsPlusConfig.GROUP, "overview." + name); } public void setOverviewSetting(String name, String value) { if (value == null || value.isEmpty()) { configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "overview." + name); } else { configManager.setConfiguration(EssentialsPlusConfig.GROUP, "overview." + name, value); } } /** In-game helper switches (attack timers) by config key. */ public boolean isHelperOption(String key) { switch (key) { case "attackTimer": return config.attackTimer(); case "enemyAttackTimer": return config.enemyAttackTimer(); case "enemyAttackStyle": return config.enemyAttackStyle(); case "itemPrices": return config.itemPrices(); case "treeCooldowns": return config.treeCooldowns(); case "itemCharges": return config.itemCharges(); default: return false; } } /** Sets a helper config value; null restores the default. */ public void setHelperOption(String key, Object value) { if (value == null) { configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, key); } else { configManager.setConfiguration(EssentialsPlusConfig.GROUP, key, value); } } /** Typed config value with the interface default applied (for the helper option menus). */ public T getHelperValue(String key, Class type, T def) { T v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, key, type); return v == null ? def : v; } public com.example.essentialsplus.helpers.ItemCharges getItemCharges() { return itemCharges; } public net.runelite.client.ui.components.colorpicker.ColorPickerManager getColorPickerManager() { return colorPickerManager; } public boolean isWikiMenuEnabled() { return config.wikiMenuOption(); } public void setWikiMenuEnabled(boolean enabled) { configManager.setConfiguration(EssentialsPlusConfig.GROUP, "wikiMenuOption", enabled); } /** Adds a "Wiki" entry for every examinable thing (npc, object, ground or inventory item) at the bottom of the menu. */ @Subscribe public void onMenuOpened(net.runelite.api.events.MenuOpened event) { if (!config.wikiMenuOption() || wikiWindows == null) { return; } net.runelite.api.Menu menu = client.getMenu(); net.runelite.api.MenuEntry[] entries = menu.getMenuEntries(); List targets = new ArrayList<>(); Set seen = new java.util.HashSet<>(); for (int i = entries.length - 1; i >= 0; i--) { net.runelite.api.MenuEntry entry = entries[i]; WikiTarget target = widgetWikiTarget(entry); if (target == null && "Examine".equalsIgnoreCase(net.runelite.client.util.Text.removeTags(entry.getOption()))) { target = wikiTarget(entry); } if (target != null && seen.add(target.type == null ? "page:" + target.name : target.type + ":" + target.id)) { targets.add(target); } } // insert directly above "Cancel"; each insert lands below the previous one, keeping the menu order int index = Math.min(1, entries.length); for (WikiTarget target : targets) { menu.createMenuEntry(index) .setOption("Wiki") .setTarget(target.menuTarget) .setType(net.runelite.api.MenuAction.RUNELITE) .onClick(e -> lookupWiki(target)); } } private static final class WikiTarget { final String type; final int id; final String name; final String menuTarget; WikiTarget(String type, int id, String name, String menuTarget) { this.type = type; this.id = id; this.name = name; this.menuTarget = menuTarget; } } private WikiTarget wikiTarget(net.runelite.api.MenuEntry entry) { String name = null; String type = null; int id = -1; switch (entry.getType()) { case EXAMINE_NPC: { net.runelite.api.NPC npc = entry.getNpc(); if (npc != null) { net.runelite.api.NPCComposition comp = npc.getTransformedComposition(); if (comp == null) { comp = npc.getComposition(); } if (comp != null) { type = "npc"; id = comp.getId(); name = comp.getName(); } } break; } case EXAMINE_OBJECT: { net.runelite.api.ObjectComposition comp = client.getObjectDefinition(entry.getIdentifier()); if (comp != null && comp.getImpostorIds() != null && comp.getImpostor() != null) { comp = comp.getImpostor(); } if (comp != null) { type = "object"; id = comp.getId(); name = comp.getName(); } break; } case EXAMINE_ITEM_GROUND: type = "item"; id = entry.getIdentifier(); break; default: if (entry.getItemId() > 0) { type = "item"; id = entry.getItemId(); } break; } if (type == null || id < 0) { return null; } if ("item".equals(type)) { id = itemManager.canonicalize(id); ItemComposition comp = itemManager.getItemComposition(id); name = comp == null ? null : comp.getName(); } name = name == null ? null : net.runelite.client.util.Text.removeTags(name).trim(); if (name == null || name.isEmpty() || name.equalsIgnoreCase("null")) { return null; } return new WikiTarget(type, id, name, entry.getTarget()); } /** Stats tab children 1..24, in widget order. */ private static final String[] STAT_SKILLS = { "Attack", "Strength", "Defence", "Ranged", "Prayer", "Magic", "Runecraft", "Construction", "Hitpoints", "Agility", "Herblore", "Thieving", "Crafting", "Fletching", "Slayer", "Hunter", "Mining", "Smithing", "Fishing", "Cooking", "Firemaking", "Woodcutting", "Farming", "Sailing" }; /** Equipment tab slot widgets mapped to their worn container slot. */ private static final Map WORN_SLOT_WIDGETS = new HashMap<>(); static { int[][] slots = { {InterfaceID.Wornitems.SLOT0, 0}, {InterfaceID.Wornitems.SLOT1, 1}, {InterfaceID.Wornitems.SLOT2, 2}, {InterfaceID.Wornitems.SLOT3, 3}, {InterfaceID.Wornitems.SLOT4, 4}, {InterfaceID.Wornitems.SLOT5, 5}, {InterfaceID.Wornitems.SLOT7, 7}, {InterfaceID.Wornitems.SLOT9, 9}, {InterfaceID.Wornitems.SLOT10, 10}, {InterfaceID.Wornitems.SLOT12, 12}, {InterfaceID.Wornitems.SLOT13, 13} }; for (int[] s : slots) { WORN_SLOT_WIDGETS.put(s[0], s[1]); } } /** Wiki targets for side-panel interfaces without an "Examine" option: prayers, equipment, combat options, skills. */ private WikiTarget widgetWikiTarget(net.runelite.api.MenuEntry entry) { if (entry.getWidget() == null) { return null; } int component = entry.getParam1(); int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(component); String option = net.runelite.client.util.Text.removeTags(entry.getOption()).trim(); String target = net.runelite.client.util.Text.removeTags(entry.getTarget()).trim(); switch (group) { case InterfaceID.PRAYERBOOK: { if (component < InterfaceID.Prayerbook.PRAYER1 || component > InterfaceID.Prayerbook.PRAYER30) { return null; } String name = target.isEmpty() ? net.runelite.client.util.Text.removeTags(entry.getWidget().getName()).trim() : target; return name.isEmpty() ? null : new WikiTarget(null, -1, name, "" + name + ""); } case InterfaceID.WORNITEMS: { int itemId = entry.getItemId(); Integer slot = WORN_SLOT_WIDGETS.get(component); if (itemId <= 0 && slot != null) { ItemContainer worn = client.getItemContainer(InventoryID.WORN); Item item = worn == null ? null : worn.getItem(slot); itemId = item == null ? -1 : item.getId(); } if (itemId <= 0) { return null; } itemId = itemManager.canonicalize(itemId); ItemComposition comp = itemManager.getItemComposition(itemId); String name = comp == null ? null : net.runelite.client.util.Text.removeTags(comp.getName()).trim(); if (name == null || name.isEmpty() || name.equalsIgnoreCase("null")) { return null; } return new WikiTarget("item", itemId, name, "" + name + ""); } case InterfaceID.COMBAT_INTERFACE: { String page; if (component == InterfaceID.CombatInterface.RETALIATE) { page = "Auto Retaliate"; } else if (component == InterfaceID.CombatInterface.SP_ATTACKBAR || component == InterfaceID.CombatInterface.SPECIAL_ATTACK) { page = "Special attacks"; } else { page = "Combat Options"; } String label = target.isEmpty() ? option : target; return new WikiTarget(null, -1, page, "" + (label.isEmpty() ? page : label) + ""); } case InterfaceID.STATS: { int child = net.runelite.api.widgets.WidgetUtil.componentToId(component); String page = child >= 1 && child <= STAT_SKILLS.length ? STAT_SKILLS[child - 1] : component == InterfaceID.Stats.TOTAL ? "Skills" : null; return page == null ? null : new WikiTarget(null, -1, page, "" + page + ""); } default: return null; } } private void lookupWiki(WikiTarget target) { if (target.type == null) { WikiWindows w = wikiWindows; if (w != null) { w.open(target.name, null); } return; } java.util.concurrent.ExecutorService ex = wikiExecutor; if (ex == null) { return; } ex.execute(() -> { String title = target.name; String anchor = null; try { WikiWindow.Target resolved = WikiWindow.parseWikiUrl(wikiApi.lookup(target.type, target.id, target.name)); if (resolved != null) { title = resolved.title; anchor = resolved.anchor; } } catch (Exception e) { log.debug("wiki lookup for {} failed", target.name, e); } WikiWindows w = wikiWindows; if (w != null) { w.open(title, anchor); } }); } /** Recently opened wiki pages, newest first. */ public List getWikiRecent() { String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT); List out = new ArrayList<>(); if (raw != null) { for (String t : raw.split("\\|")) { if (!t.trim().isEmpty()) { out.add(t.trim()); } } } return out; } private void addWikiRecent(String title) { List recent = getWikiRecent(); recent.removeIf(t -> t.equalsIgnoreCase(title)); recent.add(0, title); while (recent.size() > WIKI_RECENT_MAX) { recent.remove(recent.size() - 1); } configManager.setConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT, String.join("|", recent)); EssentialsPlusPanel p = panel; if (p != null) { p.refreshWikiLater(); } } private java.awt.Rectangle loadWikiBounds() { String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_BOUNDS); if (raw != null) { String[] parts = raw.split(","); try { if (parts.length == 4) { return new java.awt.Rectangle(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]), Integer.parseInt(parts[3])); } } catch (NumberFormatException ignored) { // fall back to the default position } } return null; } private void saveWikiBounds(java.awt.Rectangle r) { configManager.setConfiguration(EssentialsPlusConfig.GROUP, WIKI_BOUNDS, r.x + "," + r.y + "," + r.width + "," + r.height); } public static String accountKey(long accountHash, RuneScapeProfileType type) { String base = String.valueOf(accountHash); return type == null || type == RuneScapeProfileType.STANDARD ? base : base + "-" + type.name().toLowerCase(); } // ---------------------------------------------------------------- events @Subscribe public void onItemContainerChanged(ItemContainerChanged event) { combatTracker.onItemContainerChanged(event); if (fightService != null) { fightService.onContainerChanged(); } int containerId = event.getContainerId(); // Check if a reward crate / casket was just opened from inventory if (containerId == net.runelite.api.InventoryID.INVENTORY.getId() && pendingCrateName != null) { if (client.getTickCount() - pendingCrateTick <= 4) { ItemContainer inv = event.getItemContainer(); Map newInv = new HashMap<>(); if (inv != null) { for (Item item : inv.getItems()) { if (item != null && item.getId() > 0 && item.getQuantity() > 0) { newInv.merge(item.getId(), item.getQuantity(), Integer::sum); } } } List gained = new ArrayList<>(); if (invSnapshotBeforeOpen != null) { for (Map.Entry entry : newInv.entrySet()) { int id = entry.getKey(); int currentQty = entry.getValue(); int prevQty = invSnapshotBeforeOpen.getOrDefault(id, 0); if (currentQty > prevQty) { gained.add(new net.runelite.client.game.ItemStack(id, currentQty - prevQty)); } } } if (!gained.isEmpty()) { lootTrackerStore.addLoot(pendingCrateName, "EVENT", gained, 1, itemManager, client.getTickCount()); } } pendingCrateName = null; invSnapshotBeforeOpen = null; } checkChestContainers(event); trackBankAndContainers(containerId, event.getItemContainer()); for (ContainerSource c : CONTAINERS) { if (c.id == containerId) { ItemContainer changed = event.getItemContainer(); if (changed != null) { Map items = readContainer(changed); seenContainers.put(c.key, items); AccountData acc = activeAccount(); if (acc != null) { acc.containers.put(c.key, items); if (c.category == WealthCategory.BANK) { acc.bankKnown = true; bankUpdated = true; bankSeenThisSession = true; if (acc.bankPending != null && !acc.bankPending.isEmpty()) { acc.bankPending = new HashMap<>(); } } } } if (c.category == WealthCategory.BANK) { bankUpdated = true; bankSeenThisSession = true; } if (c.category == WealthCategory.EQUIPMENT) { markUrgent(); } else { markDirty(); } return; } } } private AccountData activeAccount() { if (activeKey != null) { AccountData acc = store.get(activeKey); if (acc != null) { return acc; } } if (client.getGameState() != GameState.LOGGED_IN) { return null; } Player player = client.getLocalPlayer(); long hash = client.getAccountHash(); if (player == null || player.getName() == null || hash == -1) { return null; } String name = player.getName(); RuneScapeProfileType type = RuneScapeProfileType.getCurrent(client); String key = accountKey(hash, type); return store.getOrCreate(key, name); } private boolean isBankOpen() { if (bankOpen) { return true; } net.runelite.api.widgets.Widget w = client.getWidget(ComponentID.BANK_CONTAINER); if (w != null && !w.isHidden()) { bankOpen = true; return true; } net.runelite.api.widgets.Widget itemContainer = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); if (itemContainer != null && !itemContainer.isHidden()) { bankOpen = true; return true; } return isWidgetOpen(InterfaceID.BANKMAIN) || isWidgetOpen(InterfaceID.BANKSIDE); } void deductFromBank(Map bank, int itemId, long quantity) { deductFromBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id); } static void deductFromBank(Map bank, int itemId, long quantity, java.util.function.IntUnaryOperator canonicalize) { if (bank == null || quantity <= 0) { return; } int canonical = canonicalize.applyAsInt(itemId); Long current = bank.get(itemId); if (current != null) { if (current <= quantity) { bank.remove(itemId); quantity -= current; } else { bank.put(itemId, current - quantity); return; } } if (quantity <= 0) { return; } for (Iterator> it = bank.entrySet().iterator(); it.hasNext(); ) { Map.Entry entry = it.next(); if (canonicalize.applyAsInt(entry.getKey()) == canonical) { long available = entry.getValue(); if (available <= quantity) { it.remove(); quantity -= available; } else { entry.setValue(available - quantity); quantity = 0; break; } if (quantity <= 0) { break; } } } } void addToBank(Map bank, int itemId, long quantity) { addToBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id); } static void addToBank(Map bank, int itemId, long quantity, java.util.function.IntUnaryOperator canonicalize) { if (bank == null || quantity <= 0) { return; } int canonical = canonicalize.applyAsInt(itemId); if (bank.containsKey(itemId)) { bank.merge(itemId, quantity, Long::sum); return; } for (Map.Entry entry : bank.entrySet()) { if (canonicalize.applyAsInt(entry.getKey()) == canonical) { entry.setValue(entry.getValue() + quantity); return; } } bank.merge(canonical, quantity, Long::sum); } /** * Tracks transfers between Inventory/Equipment and Bank while the bank is open, * as well as deposits into deposit boxes, ensuring bank and inventory remain strictly symmetrical. */ private void trackBankAndContainers(int containerId, ItemContainer container) { boolean inv = containerId == InventoryID.INV; if (!inv && containerId != InventoryID.WORN) { return; } Map now = container == null ? Map.of() : readContainer(container); Map before = inv ? lastInvItems : lastWornItems; if (inv) { lastInvItems = now; } else { lastWornItems = now; } AccountData acc = activeAccount(); if (acc == null) { return; } if (before == null) { Map saved = acc.containers.get(inv ? "inventory" : "equipment"); before = saved != null ? saved : Map.of(); } // 1. Transfers between inventory/equipment and main bank while bank is open if (isBankOpen()) { ItemContainer liveBank = client.getItemContainer(InventoryID.BANK); if (liveBank != null) { // the live bank is authoritative; booking the difference as well would count transfers twice // when the bank update arrives before the inventory update Map bank = readContainer(liveBank); acc.containers.put("bank", bank); acc.bankKnown = true; seenContainers.put("bank", bank); unsaved = true; markUrgent(); return; } Map bank = acc.containers.get("bank"); if (bank == null) { bank = new HashMap<>(); acc.containers.put("bank", bank); } boolean bankChanged = false; // Gained in inventory/equipment => withdrawn from bank for (Map.Entry e : now.entrySet()) { int id = e.getKey(); long currentQty = e.getValue(); long prevQty = before.getOrDefault(id, 0L); if (currentQty > prevQty) { long withdrawn = currentQty - prevQty; deductFromBank(bank, id, withdrawn); bankChanged = true; log.info("Wealth tracker: Withdrew item {} x{} from bank (into {})", id, withdrawn, inv ? "INV" : "WORN"); } } // Lost in inventory/equipment => deposited into bank for (Map.Entry e : before.entrySet()) { int id = e.getKey(); long prevQty = e.getValue(); long currentQty = now.getOrDefault(id, 0L); if (prevQty > currentQty) { long deposited = prevQty - currentQty; addToBank(bank, id, deposited); bankChanged = true; log.info("Wealth tracker: Deposited item {} x{} into bank (from {})", id, deposited, inv ? "INV" : "WORN"); } } if (bankChanged) { acc.containers.put("bank", bank); seenContainers.put("bank", new HashMap<>(bank)); unsaved = true; markUrgent(); } return; } // 2. Deposit boxes (bank interface not open) if (isWidgetOpen(InterfaceID.BANK_DEPOSITBOX) || isWidgetOpen(InterfaceID.BANK_DEPOSIT_IMP) || isWidgetOpen(InterfaceID.SEED_VAULT_DEPOSIT) || isWidgetOpen(InterfaceID.SHARED_BANK) || isWidgetOpen(InterfaceID.SHARED_BANK_SIDE)) { Map pending = new HashMap<>(acc.bankPending); boolean changed = false; for (Map.Entry e : before.entrySet()) { long gone = e.getValue() - now.getOrDefault(e.getKey(), 0L); if (gone > 0) { pending.merge(itemManager.canonicalize(e.getKey()), gone, Long::sum); changed = true; } } if (changed) { acc.bankPending = pending; unsaved = true; markUrgent(); } } } private boolean isWidgetOpen(int group) { net.runelite.api.widgets.Widget w = client.getWidget(group, 0); return w != null && !w.isHidden(); } private void checkChestContainers(ItemContainerChanged event) { int cid = event.getContainerId(); String chestName = null; if (cid == net.runelite.api.InventoryID.BARROWS_REWARD.getId()) { chestName = "Barrows Chest"; } else if (cid == net.runelite.api.InventoryID.CHAMBERS_OF_XERIC_CHEST.getId()) { chestName = "Chambers of Xeric"; } else if (cid == net.runelite.api.InventoryID.THEATRE_OF_BLOOD_CHEST.getId()) { chestName = "Theatre of Blood"; } else if (cid == net.runelite.api.InventoryID.TOA_REWARD_CHEST.getId()) { chestName = "Tombs of Amascut"; } else if (cid == net.runelite.api.InventoryID.LUNAR_CHEST.getId()) { chestName = "Lunar Chest"; } else if (cid == net.runelite.api.InventoryID.FORTIS_COLOSSEUM_REWARD_CHEST.getId()) { chestName = "Fortis Colosseum"; } else if (cid == net.runelite.api.InventoryID.WILDERNESS_LOOT_CHEST.getId()) { chestName = "Wilderness Loot Chest"; } if (chestName != null) { ItemContainer cont = event.getItemContainer(); if (cont != null) { List items = new ArrayList<>(); for (Item item : cont.getItems()) { if (item != null && item.getId() > 0 && item.getQuantity() > 0) { items.add(new net.runelite.client.game.ItemStack(item.getId(), item.getQuantity())); } } if (!items.isEmpty()) { lootTrackerStore.addLoot(chestName, "CHEST", items, 1, itemManager, client.getTickCount()); } } } } @Subscribe public void onMenuOptionClicked(net.runelite.api.events.MenuOptionClicked event) { String option = event.getMenuOption(); if (option != null && (option.equalsIgnoreCase("Open") || option.equalsIgnoreCase("Search") || option.equalsIgnoreCase("Loot"))) { int itemId = event.getItemId(); if (itemId > 0) { ItemComposition comp = itemManager.getItemComposition(itemId); if (comp != null && comp.getName() != null) { String name = comp.getName(); if (isRewardCrateOrChest(name)) { pendingCrateName = name; pendingCrateTick = client.getTickCount(); ItemContainer inv = client.getItemContainer(net.runelite.api.InventoryID.INVENTORY); invSnapshotBeforeOpen = new HashMap<>(); if (inv != null) { for (Item item : inv.getItems()) { if (item != null && item.getId() > 0 && item.getQuantity() > 0) { invSnapshotBeforeOpen.merge(item.getId(), item.getQuantity(), Integer::sum); } } } } } } } } private static boolean isRewardCrateOrChest(String name) { if (name == null) { return false; } String lower = name.toLowerCase(Locale.ENGLISH); return lower.contains("supply crate") || lower.contains("casket") || lower.contains("spoils of war") || lower.contains("bag full of gems") || lower.contains("seed pack") || lower.contains("bird nest") || lower.contains("hallowed sack") || lower.contains("ore pack") || lower.contains("lockbox") || lower.contains("reward pool"); } @Subscribe public void onHitsplatApplied(net.runelite.api.events.HitsplatApplied event) { combatTracker.onHitsplatApplied(event); } @Subscribe public void onAnimationChanged(net.runelite.api.events.AnimationChanged event) { combatTracker.onAnimationChanged(event); } @Subscribe public void onProjectileMoved(net.runelite.api.events.ProjectileMoved event) { combatTracker.onProjectileMoved(event); } @Subscribe public void onActorDeath(net.runelite.api.events.ActorDeath event) { combatTracker.onActorDeath(event); } @Subscribe public void onNpcDespawned(net.runelite.api.events.NpcDespawned event) { combatTracker.onNpcDespawned(event); } @Subscribe public void onServerNpcLoot(net.runelite.client.events.ServerNpcLoot event) { net.runelite.api.NPCComposition comp = event.getComposition(); if (comp != null && comp.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) { String rawName = net.runelite.client.util.Text.removeTags(comp.getName()); String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(rawName); long dropVal = calculateDropValue(event.getItems()); lootTrackerStore.addLoot(name, "NPC", event.getItems(), 1, itemManager, client.getTickCount()); combatTracker.onLootReceivedForEnemy(name, dropVal); } } @Subscribe public void onVarbitChanged(VarbitChanged event) { int id = event.getVarbitId(); if (!WATCHED_VARBITS.contains(id)) { return; } if (COFFER_VARBITS.contains(id)) { coffersDirty = true; } else if (BLAST_FURNACE_VARBITS.contains(id)) { // outside the Blast Furnace these read 0 - keep the last known contents if (!atBlastFurnace()) { return; } furnaceDirty = true; } else { pouchDirty = true; } markDirty(); } @Subscribe public void onGrandExchangeOfferChanged(GrandExchangeOfferChanged event) { markDirty(); checkGeOfferChanged(event.getSlot(), event.getOffer()); } @Subscribe public void onNpcLootReceived(net.runelite.client.events.NpcLootReceived event) { net.runelite.api.NPC npc = event.getNpc(); if (npc != null && npc.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) { String rawName = net.runelite.client.util.Text.removeTags(npc.getName()); String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(rawName); long dropVal = calculateDropValue(event.getItems()); lootTrackerStore.addLoot(name, "NPC", event.getItems(), 1, itemManager, client.getTickCount()); combatTracker.onLootReceivedForEnemy(name, dropVal); } } @Subscribe public void onPlayerLootReceived(net.runelite.client.events.PlayerLootReceived event) { net.runelite.api.Player player = event.getPlayer(); if (player != null && player.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) { String name = net.runelite.client.util.Text.removeTags(player.getName()); long dropVal = calculateDropValue(event.getItems()); lootTrackerStore.addLoot(player.getName(), "PLAYER", event.getItems(), 1, itemManager, client.getTickCount()); combatTracker.onLootReceivedForEnemy(name, dropVal); } } @Subscribe public void onScriptPreFired(net.runelite.api.events.ScriptPreFired event) { collectionLog.onScriptPreFired(event); if (event.getScriptId() == 7928) { int delve = -1; try { net.runelite.api.widgets.Widget widget = client.getWidget(60227586); if (widget != null) { net.runelite.api.widgets.Widget child = widget.getChild(1); if (child != null && child.getText() != null) { String text = child.getText(); // e.g. "Delve 4" or "Level 4" String[] parts = text.split(" "); if (parts.length > 1) { delve = Integer.parseInt(parts[1].trim()); combatTracker.setDoomDelve(delve); } } } } catch (Exception e) { log.debug("Failed parsing delve from script 7928", e); } recordDoomReward(delve); } } /** * Doom of Mokhaiotl rewards come from a reward interface, not an NPC drop. RuneLite's own * loot tracker only posts LootReceived for it when that plugin is enabled, so read the * reward container ourselves. If both fire, LootTrackerStore dedups by tick + items. */ private void recordDoomReward(int delve) { net.runelite.api.ItemContainer container = client.getItemContainer(DOOM_REWARD_CONTAINER); if (container == null) { return; } // mirror LootTrackerPlugin#toItemStacks so the dedup hash matches its LootReceived java.util.List items = new java.util.ArrayList<>(); for (net.runelite.api.Item item : container.getItems()) { if (item.getId() > -1) { items.add(new net.runelite.client.game.ItemStack(item.getId(), item.getQuantity())); } } if (items.isEmpty()) { return; } String name = com.example.essentialsplus.loot.combat.CombatTracker.DOOM_OF_MOKHAIOTL; lootTrackerStore.addLoot(name, "EVENT", items, -1, itemManager, client.getTickCount()); combatTracker.onLootReceivedForEnemy(name, calculateDropValue(items), Math.max(0, delve)); } @Subscribe public void onChatMessage(net.runelite.api.events.ChatMessage event) { collectionLog.onChatMessage(event); String msg = event.getMessage(); if (msg != null && (msg.contains("Delve") || msg.contains("delve"))) { java.util.regex.Matcher m = java.util.regex.Pattern.compile("(?i)delve\\s*(\\d+)").matcher(msg); if (m.find()) { try { int delve = Integer.parseInt(m.group(1)); combatTracker.setDoomDelve(delve); } catch (Exception ignored) { } } } } @Subscribe public void onLootReceived(net.runelite.client.plugins.loottracker.LootReceived event) { if (event.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) { String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(event.getName()); String typeName = event.getType() != null ? event.getType().name() : "EVENT"; int amount = event.getAmount(); long dropVal = calculateDropValue(event.getItems()); int delve = 0; if (event.getMetadata() instanceof Number) { delve = ((Number) event.getMetadata()).intValue(); } lootTrackerStore.addLoot(name, typeName, event.getItems(), amount, itemManager, client.getTickCount()); combatTracker.onLootReceivedForEnemy(name, dropVal, delve); } } private long calculateDropValue(java.util.Collection items) { if (items == null) { return 0; } long total = 0; for (net.runelite.client.game.ItemStack item : items) { if (item != null && item.getId() > 0 && item.getQuantity() > 0) { total += (long) itemManager.getItemPrice(item.getId()) * item.getQuantity(); } } return total; } /** Changes often arrive split over a few ticks (e.g. GE collect) - wait until things are quiet. */ private void markDirty() { if (!dirty) { dirtyTicks = 0; } dirty = true; quietTicks = 0; } /** Changes the player expects to see right away (bank closed, item equipped). */ private void markUrgent() { markDirty(); urgent = true; } /** Re-reads every cached source on the next valuation (login, character switch). */ private void invalidateSources() { pouchDirty = true; coffersDirty = true; furnaceDirty = true; } @Subscribe public void onWidgetLoaded(net.runelite.api.events.WidgetLoaded event) { if (event.getGroupId() == InterfaceID.BANKMAIN) { bankOpen = true; justOpenedBank = true; markUrgent(); log.info("Wealth tracker: Bank opened (WidgetLoaded 12)"); } else if (event.getGroupId() == InterfaceID.BANK_DEPOSITBOX) { markUrgent(); } } @Subscribe public void onWidgetClosed(net.runelite.api.events.WidgetClosed event) { if (event.getGroupId() == InterfaceID.BANKMAIN) { bankOpen = false; justOpenedBank = false; // save right away: closing the client does not always save, and an outdated bank breaks the next session lastSave = 0; markUrgent(); log.info("Wealth tracker: Bank closed (WidgetClosed 12)"); } else if (event.getGroupId() == InterfaceID.BANK_DEPOSITBOX) { lastSave = 0; markUrgent(); } } private boolean atBlastFurnace() { Player player = client.getLocalPlayer(); return player != null && player.getWorldLocation() != null && player.getWorldLocation().getRegionID() == BLAST_FURNACE_REGION; } @Subscribe public void onConfigChanged(ConfigChanged event) { if (EssentialsPlusConfig.GROUP.equals(event.getGroup())) { dirty = true; EssentialsPlusPanel p = panel; if (p != null && "showSkillsHud".equals(event.getKey())) { 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"))) { p.refreshHelpersLater(); } } } @Subscribe public void onGameStateChanged(GameStateChanged event) { GameState state = event.getGameState(); if (state == GameState.LOGIN_SCREEN || state == GameState.LOGIN_SCREEN_AUTHENTICATOR) { awaitingLogin = true; seenContainers.clear(); bankOpen = false; lastInvItems = null; lastWornItems = null; } if (state == GameState.LOGGED_IN && awaitingLogin) { awaitingLogin = false; bankSeenThisSession = false; // a fresh login (not a hop or loading screen) starts the "Sess" range, with the exact item state pinned long now = System.currentTimeMillis(); timeFilter.setSessionStart(now); pinLedgerAt = now; } if (state == GameState.LOGGED_IN) { invalidateSources(); markUrgent(); if (ticksSinceLogin < 0) { ticksSinceLogin = 0; } } else if (state == GameState.LOGGING_IN || state == GameState.HOPPING || state == GameState.LOGIN_SCREEN || state == GameState.CONNECTION_LOST) { ticksSinceLogin = -1; geTracker.reset(); } if (state == GameState.LOGGED_IN) { xpDirty = true; } if (state == GameState.LOGIN_SCREEN) { collectionLog.reset(); progress.reset(); currencyTracker.reset(); executor.execute(currencyStore::saveDirty); } if (state == GameState.LOGIN_SCREEN && skillKey != null) { skillKey = null; executor.execute(skillStore::saveDirty); } if (state == GameState.LOGIN_SCREEN && activeKey != null) { AccountData acc = store.get(activeKey); String key = activeKey; activeKey = null; if (acc != null) { recordFinal(acc); saveAsync(acc); saveLedgerAsync(key); } refreshPanel(); } } @Subscribe public void onGameTick(GameTick event) { collectionLog.onGameTick(); progress.onGameTick(); combatTracker.onGameTick(event); if (fightService != null) { fightService.onGameTick(); } trackCurrencies(); if (xpDirty && ticksSinceLogin >= 3) { recordXp(); } ticksSinceCompute++; quietTicks++; dirtyTicks++; if (ticksSinceLogin >= 0) { ticksSinceLogin++; if (ticksSinceLogin == 5) { syncGeOffers(); } } boolean due = dirty && (urgent || quietTicks >= COMPUTE_MIN_TICKS || dirtyTicks >= COMPUTE_MAX_DELAY_TICKS); if (due || ticksSinceCompute >= COMPUTE_IDLE_TICKS) { dirty = false; urgent = false; ticksSinceCompute = 0; compute(); } } private void trackCurrencies() { Player player = client.getLocalPlayer(); long hash = client.getAccountHash(); if (client.getGameState() != GameState.LOGGED_IN || player == null || player.getName() == null || hash == -1) { return; } currencyTracker.onGameTick(accountKey(hash, RuneScapeProfileType.getCurrent(client)), player.getName(), ticksSinceLogin); } @Subscribe public void onStatChanged(net.runelite.api.events.StatChanged event) { xpDirty = true; } private void recordXp() { Player player = client.getLocalPlayer(); long hash = client.getAccountHash(); if (client.getGameState() != GameState.LOGGED_IN || player == null || player.getName() == null || hash == -1) { return; } // hitpoints start at level 10: anything less means stats are not loaded yet if (client.getSkillExperience(net.runelite.api.Skill.HITPOINTS) < 1154) { return; } xpDirty = false; int[] xp = new int[SkillHistory.SKILLS.length]; for (int i = 0; i < xp.length; i++) { xp[i] = client.getSkillExperience(SkillHistory.SKILLS[i]); } String key = accountKey(hash, RuneScapeProfileType.getCurrent(client)); boolean switched = !key.equals(skillKey); skillKey = key; if (skillStore.record(key, player.getName(), xp, System.currentTimeMillis()) || switched) { EssentialsPlusPanel p = panel; if (p != null) { p.refreshSkillsLater(); } } } public void resetSkillsHourly(String key) { if (key == null) { return; } executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange())); EssentialsPlusPanel p = panel; if (p != null) { p.refreshSkillsLater(); } } public void resetWealthHourly(String key) { long now = System.currentTimeMillis(); com.example.essentialsplus.ui.TimeRange range = timeFilter.getRange(); EssentialsPlusPanel p = panel; com.example.essentialsplus.ui.WealthPanel wp = p != null ? p.getWealthPanel() : null; boolean isSession = (wp != null && wp.isSessionView()) || range == com.example.essentialsplus.ui.TimeRange.SESSION; boolean isCurrent = timeFilter.isCurrent() && !isSession; if (!isSession && !isCurrent) { return; } if (wp != null) { wp.resetHourly(isSession, now); } if (key == null || "ALL".equals(key)) { for (AccountData a : store.all()) { a.resetHourly(isSession); } } else { AccountData a = store.get(key); if (a != null) { a.resetHourly(isSession); } } AccountData cur = store.get(activeKey); if (cur != null) { saveAsync(cur); } if (p != null) { p.refreshWealthLater(); } } @Subscribe public void onOverlayMenuClicked(OverlayMenuClicked event) { if (event.getOverlay() == skillsHudOverlay && "Reset Hourly".equals(event.getEntry().getOption())) { resetSkillsHourly(skillKey); } } public boolean isSkillsHudEnabled() { return config.showSkillsHud(); } public void setSkillsHudEnabled(boolean enabled) { configManager.setConfiguration(EssentialsPlusConfig.GROUP, "showSkillsHud", enabled); } /** Boolean HUD options by config key, read with the config defaults applied. */ public boolean isHudOption(String key) { switch (key) { case "skillsHudLevel": return config.skillsHudLevel(); case "skillsHudPercent": return config.skillsHudPercent(); case "skillsHudXpLeft": return config.skillsHudXpLeft(); case "skillsHudTimeToLevel": return config.skillsHudTimeToLevel(); case "skillsHudXpHour": return config.skillsHudXpHour(); case "skillsHudProgressBar": return config.skillsHudProgressBar(); default: return false; } } public void setHudOption(String key, boolean enabled) { configManager.setConfiguration(EssentialsPlusConfig.GROUP, key, enabled); } /** Start of the current session (0 = none). */ public long getSessionStart() { String v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "sessionStart"); try { return v == null ? 0 : Long.parseLong(v); } catch (NumberFormatException e) { return 0; } } /** Starts a new session now: views "since session" compare against this moment. */ public void startSession() { long now = System.currentTimeMillis(); configManager.setConfiguration(EssentialsPlusConfig.GROUP, "sessionStart", String.valueOf(now)); pinLedgerAt = now; markDirty(); } /** Called from the panel's refresh button. */ public void requestUpdate() { dirty = true; executor.execute(() -> { store.reloadExternal(activeKey); refreshPanel(); }); } public void deleteAccount(String key) { clientThread.invokeLater(() -> { store.delete(key); if (key.equals(activeKey)) { dirty = true; } refreshPanel(); }); } // ---------------------------------------------------------------- calculation private boolean pricesLoaded() { return itemManager.getItemPrice(ItemID.ABYSSAL_WHIP) > 0; } private void compute() { if (client.getGameState() != GameState.LOGGED_IN) { return; } Player player = client.getLocalPlayer(); long hash = client.getAccountHash(); if (player == null || player.getName() == null || hash == -1 || !pricesLoaded()) { dirty = true; return; } String name = player.getName(); RuneScapeProfileType type = RuneScapeProfileType.getCurrent(client); String key = accountKey(hash, type); if (!key.equals(activeKey)) { AccountData previous = store.get(activeKey); if (previous != null) { recordFinal(previous); saveAsync(previous); saveLedgerAsync(previous.key); } activeKey = key; invalidateSources(); furnaceCache = new HashMap<>(); store.refreshFromDisk(key); geTracker.reset(); } AccountData acc = store.getOrCreate(key, name); acc.name = name; acc.accountHash = hash; acc.profile = type == null ? null : type.name(); long now = System.currentTimeMillis(); // Synchronize bankOpen with actual widget visibility if (bankOpen) { net.runelite.api.widgets.Widget w = client.getWidget(ComponentID.BANK_CONTAINER); net.runelite.api.widgets.Widget ic = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); boolean widgetVisible = (w != null && !w.isHidden()) || (ic != null && !ic.isHidden()); if (!widgetVisible && !isWidgetOpen(InterfaceID.BANKMAIN)) { bankOpen = false; justOpenedBank = false; } } for (ContainerSource c : CONTAINERS) { ItemContainer container = client.getItemContainer(c.id); // the contents from the last change event are the newest; the client may keep an outdated bank after closing Map items = seenContainers.remove(c.key); if (items == null && container != null) { if (c.category == WealthCategory.BANK) { // For bank, only read from client container if bank is not in memory yet, // or if the bank interface was just opened this frame to sync the initial live state if (!acc.containers.containsKey("bank") || acc.containers.get("bank").isEmpty() || justOpenedBank) { Map candidate = readContainer(container); Map existingBank = acc.containers.get("bank"); if (existingBank != null && !existingBank.isEmpty() && candidate.size() < existingBank.size() / 2) { log.debug("Wealth tracker: Bank container streaming ({} items vs {} known), deferring sync", candidate.size(), existingBank.size()); } else { items = candidate; justOpenedBank = false; log.info("Wealth tracker: Synced live bank container ({} items)", items.size()); } } } else { items = readContainer(container); } } if (items == null && container == null && ticksSinceLogin >= 10 && (c.category == WealthCategory.INVENTORY || c.category == WealthCategory.EQUIPMENT)) { // the game sends nothing for an empty inventory / no gear - don't keep an old saved state items = new HashMap<>(); } if (items != null) { if (c.category == WealthCategory.BANK) { bankSeenThisSession = true; acc.bankKnown = true; } acc.containers.put(c.key, items); } } if (bankUpdated && client.getItemContainer(InventoryID.BANK) != null) { // the real bank was seen again, items booked while it was not visible are included now acc.bankPending = new HashMap<>(); } bankUpdated = false; Map ge = Map.of(); boolean offersLive = ticksSinceLogin >= 5; if (config.includeGe()) { if (!offersLive) { // GE offers of this login are not known yet - keep showing the last value dirty = true; return; } ge = geTracker.update(acc, client, itemManager::canonicalize, true); } Valuation val = new Valuation(); valueContainers(acc, val); // Rune pouch contents live in varbits, not in a container Map pouch = acc.containers.get("runePouch"); if (pouchDirty || pouch == null) { pouchDirty = false; pouch = readRunePouch(); acc.containers.put("runePouch", pouch); } if (!pouch.isEmpty()) { val.add("Rune pouch", WealthCategory.STORAGE, pouch); } // read at login and while at the Blast Furnace only if (furnaceDirty && (atBlastFurnace() || ticksSinceLogin <= 10)) { furnaceDirty = false; Map read = new HashMap<>(); for (int[] bf : BLAST_FURNACE_ITEMS) { int qty = client.getVarbitValue(bf[0]); if (qty > 0) { read.merge(bf[1], (long) qty, Long::sum); } } furnaceCache = read; } Map furnace = furnaceCache; if (!furnace.isEmpty()) { val.add("Blast Furnace", WealthCategory.STORAGE, furnace); } if (coffersDirty) { coffersDirty = false; cofferCache[0] = client.getVarbitValue(VarbitID.MISC_COFFERS); cofferCache[1] = client.getVarbitValue(VarbitID.BLAST_FURNACE_COFFER); cofferCache[2] = client.getVarbitValue(VarbitID.NZONE_CASH); } val.addGp("Miscellania coffer", WealthCategory.COFFERS, cofferCache[0]); val.addGp("Blast Furnace coffer", WealthCategory.COFFERS, cofferCache[1]); val.addGp("Nightmare Zone coffer", WealthCategory.COFFERS, cofferCache[2]); if (!ge.isEmpty()) { val.add("GE offers", WealthCategory.GRAND_EXCHANGE, ge); } if (!Arrays.equals(acc.current, val.values)) { unsaved = true; } // no timeline points until the GE offers of this login are known apply(acc, val, now, config.snapshotSeconds() * 1000L, true); // moving tokens between bank and inventory is not earning them boolean itemsSync = ticksSinceLogin < 10 || isBankOpen() || isWidgetOpen(InterfaceID.BANK_DEPOSITBOX) || isWidgetOpen(InterfaceID.BANK_DEPOSIT_IMP) || isWidgetOpen(InterfaceID.SEED_VAULT_DEPOSIT); currencyTracker.onItems(key, name, acc.containers, acc.bankPending, itemsSync, itemManager::canonicalize); if (unsaved && now - lastSave >= SAVE_INTERVAL_MS) { saveAsync(acc); } if (now - lastLedgerSave >= LEDGER_SAVE_INTERVAL_MS) { saveLedgerAsync(key); } refreshPanel(); } /** Stores a valuation into the account: current state, top items, timeline point and item snapshot. */ private void apply(AccountData acc, Valuation val, long now, long minGap, boolean record) { acc.current = val.values; acc.sources = val.sources; acc.topItems = topItems(val); acc.lastUpdated = now; if (!acc.bankKnown || !record) { return; } if (acc.key.equals(activeKey) && ticksSinceLogin < 10) { // inventory / equipment may still hold the last session's state: pinning the session start on them // would show the difference to the live state as activity return; } ItemLedger ledger = store.ledger(acc.key, true); acc.market += marketEffect(ledger.last(), val); Snapshot last = acc.lastSnapshot(); long valTotal = val.total(); // Check if the previous snapshot(s) in history were an isolated transient spike that now reverted if (last != null && acc.history.size() >= 2) { Snapshot prev = acc.history.get(acc.history.size() - 2); if (AccountData.isTransientSpike(prev.t, prev.total(), last.t, last.total(), now, valTotal)) { acc.history.remove(acc.history.size() - 1); last = acc.lastSnapshot(); unsaved = true; } else if (acc.history.size() >= 3) { Snapshot prev2 = acc.history.get(acc.history.size() - 3); if (AccountData.isDoubleTransientSpike(prev2.t, prev2.total(), prev.t, prev.total(), last.t, last.total(), now, valTotal)) { acc.history.remove(acc.history.size() - 1); acc.history.remove(acc.history.size() - 1); last = acc.lastSnapshot(); unsaved = true; } else if (acc.history.size() >= 4) { Snapshot prev3 = acc.history.get(acc.history.size() - 4); if (AccountData.isTripleTransientSpike(prev3.t, prev3.total(), prev2.t, prev2.total(), prev.t, prev.total(), last.t, last.total(), now, valTotal)) { acc.history.remove(acc.history.size() - 1); acc.history.remove(acc.history.size() - 1); acc.history.remove(acc.history.size() - 1); last = acc.lastSnapshot(); unsaved = true; } } } } boolean differs = last == null || !last.sameValues(val.values); if (differs && (last == null || now - last.t >= minGap)) { Snapshot point = new Snapshot(now, val.values.clone()); point.m = acc.market; acc.history.add(point); unsaved = true; } long pin = pinLedgerAt; if (pin > 0 && acc.key.equals(activeKey)) { // exact item state at the session start so "what changed" can compare against it pinLedgerAt = 0; ItemSnapshot pinned = val.itemSnapshot(pin); pinned.k = true; ledger.snapshots.add(pinned); ledger.dirty = true; return; } ItemSnapshot snap = val.itemSnapshot(now); ItemSnapshot prev = ledger.last(); if (prev == null || !prev.sameAs(snap)) { boolean sameHour = prev != null && !Boolean.TRUE.equals(prev.k) && now / HOUR_MS == prev.t / HOUR_MS; if (sameHour && ledger.snapshots.size() > 1) { ledger.snapshots.set(ledger.snapshots.size() - 1, snap); } else { ledger.snapshots.add(snap); } ledger.dirty = true; } } /** Value change of the items held at the previous valuation that comes only from their price changing. */ private static long marketEffect(ItemSnapshot prev, Valuation val) { if (prev == null || prev.ids == null) { return 0; } long effect = 0; for (int i = 0; i < prev.ids.length; i++) { Integer price = val.prices.get(prev.ids[i]); if (price != null && price > 0 && prev.p[i] > 0) { effect += prev.q[i] * (long) (price - prev.p[i]); } } return effect; } private void valueContainers(AccountData acc, Valuation val) { for (ContainerSource c : CONTAINERS) { Map items = acc.containers.get(c.key); if (c.category == WealthCategory.BANK && items != null && !acc.bankPending.isEmpty()) { Map withPending = new HashMap<>(items); acc.bankPending.forEach((id, q) -> withPending.merge(id, q, Long::sum)); items = withPending; } if (items != null) { val.add(c.label, c.category, items); } } } /** * Re-prices characters that are not logged in (in any client) with current GE prices, * so market movements show up in their timeline too. */ private void revalueOffline() { if (!config.revalueOffline() || client.getGameState().ordinal() < GameState.LOGIN_SCREEN.ordinal() || !pricesLoaded()) { return; } long now = System.currentTimeMillis(); boolean any = false; for (AccountData acc : store.all()) { if (acc.key.equals(activeKey) || acc.containers.isEmpty() || now - acc.lastUpdated < OFFLINE_GRACE_MS || now - lastRevalue.getOrDefault(acc.key, 0L) < HOUR_MS) { continue; } lastRevalue.put(acc.key, now); Valuation val = new Valuation(); valueContainers(acc, val); // keep non-container sources (rune pouch, coffers, GE) at their last known value Set containerLabels = new java.util.HashSet<>(); for (ContainerSource c : CONTAINERS) { containerLabels.add(c.label); } for (SourceValue s : acc.sources) { if (!containerLabels.contains(s.name) && s.category < WealthCategory.COUNT) { val.addGp(s.name, WealthCategory.values()[s.category], s.value); } } long lastUpdated = acc.lastUpdated; apply(acc, val, now, HOUR_MS, true); acc.lastUpdated = lastUpdated; WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), now); AccountData ref = acc; executor.execute(() -> { store.save(ref); ItemLedger ledger = store.ledger(ref.key, false); WealthStore.compact(ledger, now); store.saveLedger(ref.key, ledger); }); any = true; } if (any) { refreshPanel(); } } /** Writes the latest known value so the timeline ends exactly where the session ended. */ private void recordFinal(AccountData acc) { if (!acc.bankKnown || acc.current == null) { return; } Snapshot last = acc.lastSnapshot(); if (last == null || !last.sameValues(acc.current)) { Snapshot point = new Snapshot(System.currentTimeMillis(), acc.current.clone()); point.m = acc.market; acc.history.add(point); } } private void saveAsync(AccountData acc) { long now = System.currentTimeMillis(); WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), now); lastSave = now; unsaved = false; executor.execute(() -> store.save(acc)); } private void saveLedgerAsync(String key) { long now = System.currentTimeMillis(); lastLedgerSave = now; ItemLedger ledger = store.ledger(key, true); WealthStore.compact(ledger, now); if (ledger.dirty) { executor.execute(() -> store.saveLedger(key, ledger)); } } private Map readContainer(ItemContainer container) { Map items = new HashMap<>(); for (Item item : container.getItems()) { if (item == null || item.getId() <= 0 || item.getQuantity() <= 0 || isPlaceholder(item.getId())) { continue; } items.merge(item.getId(), (long) item.getQuantity(), Long::sum); } return items; } /** Bank placeholders canonicalize to the real item, so they would be valued as if the item was still banked. */ private boolean isPlaceholder(int itemId) { ItemComposition comp = itemManager.getItemComposition(itemId); return comp != null && comp.getPlaceholderTemplateId() != -1; } private Map readRunePouch() { Map items = new HashMap<>(); EnumComposition runes = client.getEnum(RUNEPOUCH_RUNE_ENUM); for (int i = 0; i < RUNE_POUCH_TYPES.length; i++) { int type = client.getVarbitValue(RUNE_POUCH_TYPES[i]); int qty = client.getVarbitValue(RUNE_POUCH_QUANTITIES[i]); if (type <= 0 || qty <= 0 || runes == null) { continue; } int itemId = runes.getIntValue(type); if (itemId > 0) { items.merge(itemId, (long) qty, Long::sum); } } return items; } private int price(int canonicalId, Map priceCache) { return priceCache.computeIfAbsent(canonicalId, id -> { if (id == ItemID.COINS) { return 1; } if (id == ItemID.PLATINUM) { return 1000; } return Math.max(0, itemManager.getItemPrice(id)); }); } private List topItems(Valuation val) { List list = new ArrayList<>(); for (Map.Entry e : val.merged.entrySet()) { long value = price(e.getKey(), val.prices) * e.getValue(); if (value > 0) { list.add(new TopItem(e.getKey(), null, e.getValue(), value)); } } list.sort((a, b) -> Long.compare(b.value, a.value)); if (list.size() > TOP_ITEMS) { list = new ArrayList<>(list.subList(0, TOP_ITEMS)); } for (TopItem item : list) { item.name = itemName(item.id); } return list; } private String itemName(int id) { ItemComposition comp = itemManager.getItemComposition(id); return comp != null ? comp.getName() : "Item " + id; } /** * Collects the priced items of one view ("bank", "inventory", "equipment", "lootingBag", "seedVault", * "runePouch", "storage", "ge", "coffers", "all") over the given characters, sorted by value. * Runs on the client thread, calls back on the Swing thread. */ public void loadItems(List accounts, String view, Consumer> callback) { clientThread.invokeLater(() -> { Map merged = new HashMap<>(); List rows = new ArrayList<>(); for (AccountData acc : accounts) { for (String key : viewContainers(view)) { if (key.equals("ge")) { if (acc.geSlots != null) { for (com.example.essentialsplus.data.GeSlot slot : acc.geSlots) { if (slot != null) { merged.merge(itemManager.canonicalize(slot.item), slot.escrowItems + slot.collectItems, Long::sum); merged.merge(ItemID.COINS, slot.escrowCoins + slot.collectCoins, Long::sum); } } } continue; } if (key.equals("coffers")) { for (SourceValue sv : acc.sources) { if (sv.category == WealthCategory.COFFERS.ordinal() && sv.value > 0) { rows.add(new com.example.essentialsplus.data.ItemRow(ItemID.COINS, (accounts.size() > 1 ? acc.name + ": " : "") + sv.name, sv.value, 1)); } } continue; } Map items = acc.containers.get(key); if (items != null) { items.forEach((id, q) -> { if (!isPlaceholder(id)) { merged.merge(itemManager.canonicalize(id), q, Long::sum); } }); } if (key.equals("bank")) { acc.bankPending.forEach((id, q) -> merged.merge(itemManager.canonicalize(id), q, Long::sum)); } } } Map prices = new HashMap<>(); merged.forEach((id, q) -> { if (q > 0) { rows.add(new com.example.essentialsplus.data.ItemRow(id, itemName(id), q, price(id, prices))); } }); rows.sort((a, b) -> b.value != a.value ? Long.compare(b.value, a.value) : Long.compare(b.qty, a.qty)); javax.swing.SwingUtilities.invokeLater(() -> callback.accept(rows)); }); } /** Grand Exchange slots of the given characters with item names and current GE prices. */ public void loadGe(List accounts, Consumer> callback) { clientThread.invokeLater(() -> { List rows = new ArrayList<>(); Map prices = new HashMap<>(); for (AccountData acc : accounts) { com.example.essentialsplus.data.GeSlot[] slots = acc.geSlots; for (int i = 0; i < 8; i++) { com.example.essentialsplus.data.GeSlot s = slots != null && i < slots.length ? slots[i] : null; if (s == null) { rows.add(new com.example.essentialsplus.data.GeRow(acc.name, i, null, null, 0)); } else { int id = itemManager.canonicalize(s.item); GeLimitInfo limitInfo = null; if (s.buy) { ItemStats stats = itemManager.getItemStats(id); int geLimit = stats != null ? stats.getGeLimit() : 0; limitInfo = getBuyLimitInfo(acc, id, geLimit); } rows.add(new com.example.essentialsplus.data.GeRow(acc.name, i, s, itemName(id), price(id, prices), limitInfo)); } } } javax.swing.SwingUtilities.invokeLater(() -> callback.accept(rows)); }); } private static List viewContainers(String view) { switch (view) { case "storage": return List.of("lootingBag", "seedVault", "runePouch"); case "all": return List.of("bank", "inventory", "equipment", "lootingBag", "seedVault", "runePouch", "ge"); default: return List.of(view); } } /** Resolves item names on the client thread, then calls back on the Swing thread. */ public void resolveItemNames(java.util.Collection ids, Consumer> callback) { clientThread.invokeLater(() -> { Map names = new HashMap<>(); for (int id : ids) { names.put(id, itemName(id)); } javax.swing.SwingUtilities.invokeLater(() -> callback.accept(names)); }); } // ---------------------------------------------------------------- GE buy limit tracking private void syncGeOffers() { if (client.getGameState() != GameState.LOGGED_IN || activeKey == null) { return; } GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); if (offers == null) { return; } for (int i = 0; i < offers.length; i++) { checkGeOfferChanged(i, offers[i]); } } private void checkGeOfferChanged(int slot, GrandExchangeOffer offer) { if (slot < 0 || slot >= 8) { return; } AccountData acc = store.get(activeKey); if (acc == null) { return; } if (acc.geBuyLimitSlots == null || acc.geBuyLimitSlots.length != 8) { acc.geBuyLimitSlots = new AccountData.GeBuyLimitSlot[8]; } if (offer == null || offer.getItemId() <= 0 || offer.getState() == GrandExchangeOfferState.EMPTY) { if (client.getGameState() == GameState.LOGGED_IN) { acc.geBuyLimitSlots[slot] = null; } return; } GrandExchangeOfferState state = offer.getState(); boolean buy = state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT || state == GrandExchangeOfferState.CANCELLED_BUY; if (!buy) { acc.geBuyLimitSlots[slot] = null; return; } int canonicalId = itemManager.canonicalize(offer.getItemId()); AccountData.GeBuyLimitSlot prev = acc.geBuyLimitSlots[slot]; AccountData.GeBuyLimitSlot current = new AccountData.GeBuyLimitSlot( offer.getItemId(), offer.getPrice(), offer.getTotalQuantity(), offer.getQuantitySold(), offer.getSpent() ); boolean sameOffer = current.isSameOffer(prev) && current.quantity >= prev.quantity; int quantityDelta = sameOffer ? (current.quantity - prev.quantity) : current.quantity; if (quantityDelta > 0) { long now = System.currentTimeMillis(); acc.recordBuy(canonicalId, quantityDelta, now); GeBuyLimit limit = acc.geBuyLimits != null ? acc.geBuyLimits.get(canonicalId) : null; if (limit != null && limit.windowStart > 0) { try { configManager.setRSProfileConfiguration("grandexchange", String.valueOf(canonicalId), Instant.ofEpochMilli(limit.windowStart + AccountData.FOUR_HOURS_MS)); } catch (Exception ignored) { } } } acc.geBuyLimitSlots[slot] = current; } @Subscribe public void onClientTick(ClientTick event) { updateGeBuyLimitWidgets(); } @Subscribe public void onScriptCallbackEvent(ScriptCallbackEvent event) { if (!config.showGeBuyLimits()) { return; } if ("geBuyExamineText".equals(event.getEventName())) { Object[] objectStack = client.getObjectStack(); int objectStackSize = client.getObjectStackSize(); if (objectStack == null || objectStackSize < 1) { return; } int itemId = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); if (itemId <= 0) { return; } itemId = itemManager.canonicalize(itemId); ItemStats stats = itemManager.getItemStats(itemId); int geLimit = stats != null ? stats.getGeLimit() : 0; AccountData acc = store.get(activeKey); GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); if (info != null && (info.limit > 0 || info.bought > 0)) { String current = (String) objectStack[objectStackSize - 1]; if (current == null) { current = ""; } String limitLine = formatBuyLimitLine(info); String base = stripBuyLimitLine(current); String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; objectStack[objectStackSize - 1] = updated; } } } private void updateGeBuyLimitWidgets() { if (!config.showGeBuyLimits() || client.getGameState() != GameState.LOGGED_IN) { return; } Widget geUniverse = client.getWidget(InterfaceID.GE_OFFERS, 0); if (geUniverse == null || geUniverse.isHidden()) { return; } AccountData acc = store.get(activeKey); // 1. DETAILS screen (child 15: DETAILS container, child 16: DETAILS_DESC) Widget details = client.getWidget(InterfaceID.GE_OFFERS, 15); Widget detailsDesc = client.getWidget(InterfaceID.GE_OFFERS, 16); if (details != null && !details.isHidden() && detailsDesc != null && !detailsDesc.isHidden()) { updateDetailsWidget(acc, details, detailsDesc); } // 2. SETUP screen (child 26: SETUP container, child 27: SETUP_DESC) Widget setup = client.getWidget(InterfaceID.GE_OFFERS, 26); Widget setupDesc = client.getWidget(InterfaceID.GE_OFFERS, 27); if (setup != null && !setup.isHidden() && setupDesc != null && !setupDesc.isHidden()) { updateSetupWidget(acc, setup, setupDesc); } } private void updateDetailsWidget(AccountData acc, Widget details, Widget detailsDesc) { if (!isGeDetailsBuyOffer(details)) { return; } int itemId = getGeDetailsItemId(details); if (itemId <= 0) { return; } itemId = itemManager.canonicalize(itemId); ItemStats stats = itemManager.getItemStats(itemId); int geLimit = stats != null ? stats.getGeLimit() : 0; GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); if (info == null || (info.limit <= 0 && info.bought <= 0)) { return; } String limitLine = formatBuyLimitLine(info); String current = detailsDesc.getText(); if (current == null) { current = ""; } String base = stripBuyLimitLine(current); String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; if (!updated.equals(current)) { detailsDesc.setText(updated); if (detailsDesc.getOriginalHeight() < 38) { detailsDesc.setOriginalHeight(38); detailsDesc.revalidate(); } } } private void updateSetupWidget(AccountData acc, Widget setup, Widget setupDesc) { int offerType = client.getVarbitValue(Varbits.GE_OFFER_CREATION_TYPE); if (offerType != 0 && !hasWidgetWithText(setup, "Buy")) { return; } int varpItem = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); int itemId = varpItem > 0 ? varpItem : findItemIdInWidget(setup); if (itemId <= 0) { return; } itemId = itemManager.canonicalize(itemId); ItemStats stats = itemManager.getItemStats(itemId); int geLimit = stats != null ? stats.getGeLimit() : 0; GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); if (info == null || (info.limit <= 0 && info.bought <= 0)) { return; } String limitLine = formatBuyLimitLine(info); String current = setupDesc.getText(); if (current == null) { current = ""; } String base = stripBuyLimitLine(current); String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; if (!updated.equals(current)) { setupDesc.setText(updated); if (setupDesc.getOriginalHeight() < 38) { setupDesc.setOriginalHeight(38); setupDesc.revalidate(); } } } private boolean isGeDetailsBuyOffer(Widget details) { if (details == null) { return true; } if (hasWidgetWithText(details, "Buy offer")) { return true; } if (hasWidgetWithText(details, "Sell offer")) { return false; } int id = getGeDetailsItemId(details); if (id > 0) { GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); if (offers != null) { for (GrandExchangeOffer o : offers) { if (o != null && itemManager.canonicalize(o.getItemId()) == id) { GrandExchangeOfferState state = o.getState(); return state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT || state == GrandExchangeOfferState.CANCELLED_BUY; } } } } return true; } private int getGeDetailsItemId(Widget details) { int varp = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); if (varp > 0) { return varp; } for (int c = 15; c <= 25; c++) { Widget w = client.getWidget(InterfaceID.GE_OFFERS, c); if (w != null) { int id = findItemIdInWidget(w); if (id > 0) { return id; } } } if (details != null) { int id = findItemIdInWidget(details); if (id > 0) { return id; } GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); if (offers != null) { for (GrandExchangeOffer o : offers) { if (o != null && o.getItemId() > 0) { ItemComposition comp = itemManager.getItemComposition(o.getItemId()); if (comp != null && hasWidgetWithText(details, comp.getName())) { return o.getItemId(); } } } } } return -1; } private static int findItemIdInWidget(Widget root) { if (root == null) { return -1; } if (root.getItemId() > 0) { return root.getItemId(); } Widget[] children = root.getChildren(); if (children != null) { for (Widget c : children) { int id = findItemIdInWidget(c); if (id > 0) { return id; } } } Widget[] dynamic = root.getDynamicChildren(); if (dynamic != null) { for (Widget c : dynamic) { int id = findItemIdInWidget(c); if (id > 0) { return id; } } } Widget[] nested = root.getNestedChildren(); if (nested != null) { for (Widget c : nested) { int id = findItemIdInWidget(c); if (id > 0) { return id; } } } Widget[] stat = root.getStaticChildren(); if (stat != null) { for (Widget c : stat) { int id = findItemIdInWidget(c); if (id > 0) { return id; } } } return -1; } private static boolean hasWidgetWithText(Widget root, String textToFind) { if (root == null || textToFind == null) { return false; } String txt = root.getText(); if (txt != null && txt.toLowerCase().contains(textToFind.toLowerCase())) { return true; } Widget[] children = root.getChildren(); if (children != null) { for (Widget c : children) { if (hasWidgetWithText(c, textToFind)) { return true; } } } Widget[] dynamic = root.getDynamicChildren(); if (dynamic != null) { for (Widget c : dynamic) { if (hasWidgetWithText(c, textToFind)) { return true; } } } Widget[] nested = root.getNestedChildren(); if (nested != null) { for (Widget c : nested) { if (hasWidgetWithText(c, textToFind)) { return true; } } } Widget[] stat = root.getStaticChildren(); if (stat != null) { for (Widget c : stat) { if (hasWidgetWithText(c, textToFind)) { return true; } } } return false; } public GeLimitInfo getBuyLimitInfo(AccountData acc, int itemId, int itemGeLimit) { long now = System.currentTimeMillis(); if (acc != null) { GeLimitInfo info = acc.getBuyLimitInfo(itemId, itemGeLimit, now); if (info.windowActive) { return info; } } try { Instant rlReset = configManager.getRSProfileConfiguration("grandexchange", String.valueOf(itemId), Instant.class); if (rlReset != null && rlReset.isAfter(Instant.now())) { long resetInMs = Duration.between(Instant.now(), rlReset).toMillis(); int bought = acc != null && acc.geBuyLimits != null && acc.geBuyLimits.containsKey(itemId) ? acc.geBuyLimits.get(itemId).bought : 0; if (bought > 0) { int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0); return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true); } } } catch (Exception ignored) { } return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false); } public static String formatBuyLimitLine(GeLimitInfo info) { if (info == null) { return ""; } if (info.limit > 0) { String formattedBought = String.format(Locale.US, "%,d", info.bought); String formattedLimit = String.format(Locale.US, "%,d", info.limit); String formattedRemaining = String.format(Locale.US, "%,d", info.remaining); if (info.remaining <= 0 && info.windowActive) { String time = info.resetInMs > 0 ? " - reset in " + formatDuration(info.resetInMs) + "" : ""; return "Buy limit (4h): " + formattedLimit + " / " + formattedLimit + " (0 left)" + time; } else if (info.windowActive && info.resetInMs > 0) { return "Buy limit (4h): " + formattedBought + " / " + formattedLimit + " (" + formattedRemaining + " left) - reset in " + formatDuration(info.resetInMs) + ""; } else { return "Buy limit (4h): 0 / " + formattedLimit + " (" + formattedLimit + " left)"; } } else if (info.bought > 0) { String formattedBought = String.format(Locale.US, "%,d", info.bought); String time = info.resetInMs > 0 ? " - reset in " + formatDuration(info.resetInMs) + "" : ""; return "Bought in 4h: " + formattedBought + "" + time; } return ""; } public static String formatDuration(long ms) { long m = Math.max(0, ms) / 60_000; if (m < 60) { return m + "m"; } long h = m / 60; if (h < 24) { return h + "h " + (m % 60) + "m"; } return (h / 24) + "d " + (h % 24) + "h"; } public static String stripBuyLimitLine(String text) { if (text == null || text.isEmpty()) { return ""; } String[] lines = text.split("
"); StringBuilder sb = new StringBuilder(); for (String line : lines) { String lower = line.toLowerCase(); if (lower.contains("buy limit") || lower.contains("bought in 4h")) { continue; } if (sb.length() > 0) { sb.append("
"); } sb.append(line); } return sb.toString(); } // ---------------------------------------------------------------- export /** Writes timeline and item history CSVs of the given characters to essentials-plus/export. */ public void exportCsv(List accounts, Runnable done) { clientThread.invokeLater(() -> { java.util.Set ids = new java.util.TreeSet<>(); Map ledgers = new HashMap<>(); for (AccountData a : accounts) { ItemLedger l = store.ledger(a.key, a.key.equals(activeKey)); ledgers.put(a.key, l); for (ItemSnapshot s : l.snapshots) { for (int id : s.ids) { ids.add(id); } } } Map names = new HashMap<>(); for (int id : ids) { names.put(id, itemName(id)); } executor.execute(() -> { try { writeExport(accounts, ledgers, names); } catch (IOException e) { log.warn("Wealth tracker: export failed", e); } if (done != null) { javax.swing.SwingUtilities.invokeLater(done); } }); }); } private static void writeExport(List accounts, Map ledgers, Map names) throws IOException { File dir = new File(WealthStore.DIR, "export"); Files.createDirectories(dir.toPath()); DateTimeFormatter iso = DateTimeFormatter.ISO_OFFSET_DATE_TIME; ZoneId zone = ZoneId.systemDefault(); String date = LocalDate.now().toString(); for (AccountData a : accounts) { String base = WealthStore.fileBase(a.name == null ? a.key : a.name) + "-" + date; try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(new File(dir, base + "-timeline.csv").toPath(), StandardCharsets.UTF_8))) { StringBuilder header = new StringBuilder("time,epoch_ms,resolution"); for (WealthCategory c : WealthCategory.values()) { header.append(',').append(c.name().toLowerCase()); } header.append(",total,open,close,low,high"); w.println(header); for (Snapshot s : a.history) { StringBuilder row = new StringBuilder(); row.append(Instant.ofEpochMilli(s.t).atZone(zone).format(iso)).append(',').append(s.t).append(',') .append(s.g == null ? "raw" : s.g.equals(Snapshot.HOUR) ? "hour" : "day"); for (WealthCategory c : WealthCategory.values()) { row.append(',').append(s.get(c)); } row.append(',').append(s.total()).append(',').append(s.open()).append(',').append(s.close()) .append(',').append(s.low()).append(',').append(s.high()); w.println(row); } } ItemLedger ledger = ledgers.get(a.key); if (ledger != null && !ledger.snapshots.isEmpty()) { try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(new File(dir, base + "-items.csv").toPath(), StandardCharsets.UTF_8))) { w.println("time,epoch_ms,item_id,item_name,quantity,price,value"); for (ItemSnapshot s : ledger.snapshots) { String time = Instant.ofEpochMilli(s.t).atZone(zone).format(iso); for (int i = 0; i < s.ids.length; i++) { String n = names.getOrDefault(s.ids[i], "").replace("\"", "\"\""); w.println(time + ',' + s.t + ',' + s.ids[i] + ",\"" + n + "\"," + s.q[i] + ',' + s.p[i] + ',' + (s.q[i] * s.p[i])); } } } } } } // ---------------------------------------------------------------- helpers /** Accumulates values per category/source plus merged canonical holdings with prices. */ private final class Valuation { final long[] values = new long[WealthCategory.COUNT]; final List sources = new ArrayList<>(); final Map merged = new HashMap<>(); final Map prices = new HashMap<>(); long total() { long sum = 0; for (long v : values) { sum += v; } return sum; } void add(String label, WealthCategory category, Map items) { long total = 0; for (Map.Entry e : items.entrySet()) { if (isPlaceholder(e.getKey())) { // saved banks from older versions may still contain placeholders continue; } int id = itemManager.canonicalize(e.getKey()); long qty = e.getValue(); total += price(id, prices) * qty; merged.merge(id, qty, Long::sum); } values[category.ordinal()] += total; sources.add(new SourceValue(label, category.ordinal(), total)); } void addGp(String label, WealthCategory category, long gp) { if (gp > 0) { values[category.ordinal()] += gp; sources.add(new SourceValue(label, category.ordinal(), gp)); } } ItemSnapshot itemSnapshot(long t) { TreeMap sorted = new TreeMap<>(merged); int[] ids = new int[sorted.size()]; long[] q = new long[sorted.size()]; int[] p = new int[sorted.size()]; int i = 0; for (Map.Entry e : sorted.entrySet()) { ids[i] = e.getKey(); q[i] = e.getValue(); p[i] = price(e.getKey(), prices); i++; } return new ItemSnapshot(t, ids, q, p); } } private static BufferedImage loadIcon() { try { BufferedImage img = ImageUtil.loadImageResource(EssentialsPlusPlugin.class, "/com/example/essentialsplus/icon.png"); if (img != null) { return img; } } catch (Exception ignored) { } return createIcon(); } private static BufferedImage createIcon() { BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB); Graphics2D g = img.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // Background dark rounded rect g.setColor(new Color(35, 35, 42)); g.fillRoundRect(0, 0, 16, 16, 4, 4); // Border in warm orange g.setColor(new Color(225, 145, 20)); g.drawRoundRect(0, 0, 15, 15, 4, 4); // E letter in white g.setColor(Color.WHITE); // Vertical bar g.fillRect(3, 4, 2, 8); // Top bar g.fillRect(5, 4, 3, 2); // Middle bar g.fillRect(5, 7, 2, 2); // Bottom bar g.fillRect(5, 10, 3, 2); // + symbol in orange g.setColor(new Color(255, 175, 40)); // Vertical plus g.fillRect(11, 5, 2, 6); // Horizontal plus g.fillRect(9, 7, 6, 2); g.dispose(); return img; } private static final class ContainerSource { final int id; final String key; final String label; final WealthCategory category; ContainerSource(int id, String key, String label, WealthCategory category) { this.id = id; this.key = key; this.label = label; this.category = category; } } }