Files
Runelite-EssentialsPlus/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java
T
2026-09-18 17:18:57 +02:00

3527 lines
98 KiB
Java

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<Integer> 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<Integer> COFFER_VARBITS = Set.of(VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH);
private static final Set<Integer> 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<Integer, Integer> 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<Integer, Long> furnaceCache = new HashMap<>();
private int ticksSinceLogin = -1;
private boolean bankUpdated;
private volatile boolean bankOpen;
private volatile boolean justOpenedBank;
private Map<Integer, Long> 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<String, Map<Integer, Long>> seenContainers = new java.util.concurrent.ConcurrentHashMap<>();
private Map<Integer, Long> lastWornItems;
private final GrandExchangeTracker geTracker = new GrandExchangeTracker();
private long lastSave;
private long lastLedgerSave;
private boolean unsaved;
private final Map<String, Long> 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.<name>"), 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> T getHelperValue(String key, Class<T> 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<WikiTarget> targets = new ArrayList<>();
Set<String> 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<Integer, Integer> 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, "<col=ff9040>" + name + "</col>");
}
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, "<col=ff9040>" + name + "</col>");
}
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, "<col=ff9040>" + (label.isEmpty() ? page : label) + "</col>");
}
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, "<col=ff9040>" + page + "</col>");
}
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<String> getWikiRecent()
{
String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT);
List<String> 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<String> 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<Integer, Integer> 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<net.runelite.client.game.ItemStack> gained = new ArrayList<>();
if (invSnapshotBeforeOpen != null)
{
for (Map.Entry<Integer, Integer> 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<Integer, Long> 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<Integer, Long> bank, int itemId, long quantity)
{
deductFromBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id);
}
static void deductFromBank(Map<Integer, Long> 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<Map.Entry<Integer, Long>> it = bank.entrySet().iterator(); it.hasNext(); )
{
Map.Entry<Integer, Long> 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<Integer, Long> bank, int itemId, long quantity)
{
addToBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id);
}
static void addToBank(Map<Integer, Long> 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<Integer, Long> 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<Integer, Long> now = container == null ? Map.of() : readContainer(container);
Map<Integer, Long> before = inv ? lastInvItems : lastWornItems;
if (inv)
{
lastInvItems = now;
}
else
{
lastWornItems = now;
}
AccountData acc = activeAccount();
if (acc == null)
{
return;
}
if (before == null)
{
Map<Integer, Long> 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<Integer, Long> bank = readContainer(liveBank);
acc.containers.put("bank", bank);
acc.bankKnown = true;
seenContainers.put("bank", bank);
unsaved = true;
markUrgent();
return;
}
Map<Integer, Long> 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<Integer, Long> 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<Integer, Long> 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<Integer, Long> pending = new HashMap<>(acc.bankPending);
boolean changed = false;
for (Map.Entry<Integer, Long> 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<net.runelite.client.game.ItemStack> 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<net.runelite.client.game.ItemStack> 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<net.runelite.client.game.ItemStack> 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<Integer, Long> 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<Integer, Long> candidate = readContainer(container);
Map<Integer, Long> 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<Integer, Long> 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<Integer, Long> 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<Integer, Long> 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<Integer, Long> 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<Integer, Long> items = acc.containers.get(c.key);
if (c.category == WealthCategory.BANK && items != null && !acc.bankPending.isEmpty())
{
Map<Integer, Long> 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<String> 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<Integer, Long> readContainer(ItemContainer container)
{
Map<Integer, Long> 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<Integer, Long> readRunePouch()
{
Map<Integer, Long> 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<Integer, Integer> 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<TopItem> topItems(Valuation val)
{
List<TopItem> list = new ArrayList<>();
for (Map.Entry<Integer, Long> 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<AccountData> accounts, String view, Consumer<List<com.example.essentialsplus.data.ItemRow>> callback)
{
clientThread.invokeLater(() ->
{
Map<Integer, Long> merged = new HashMap<>();
List<com.example.essentialsplus.data.ItemRow> 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<Integer, Long> 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<Integer, Integer> 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<AccountData> accounts, Consumer<List<com.example.essentialsplus.data.GeRow>> callback)
{
clientThread.invokeLater(() ->
{
List<com.example.essentialsplus.data.GeRow> rows = new ArrayList<>();
Map<Integer, Integer> 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<String> 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<Integer> ids, Consumer<Map<Integer, String>> callback)
{
clientThread.invokeLater(() ->
{
Map<Integer, String> 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 + "<br>" + 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 + "<br>" + 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 + "<br>" + 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 ? " - <col=ffff00>reset in " + formatDuration(info.resetInMs) + "</col>" : "";
return "<col=ff5050>Buy limit (4h): " + formattedLimit + " / " + formattedLimit + " (0 left)</col>" + time;
}
else if (info.windowActive && info.resetInMs > 0)
{
return "<col=ff981f>Buy limit (4h):</col> <col=ffffff>" + formattedBought + "</col> / <col=ff981f>"
+ formattedLimit + "</col> <col=70d070>(" + formattedRemaining + " left)</col> - <col=ffff00>reset in "
+ formatDuration(info.resetInMs) + "</col>";
}
else
{
return "<col=ff981f>Buy limit (4h):</col> <col=ffffff>0</col> / <col=ff981f>"
+ formattedLimit + "</col> <col=70d070>(" + formattedLimit + " left)</col>";
}
}
else if (info.bought > 0)
{
String formattedBought = String.format(Locale.US, "%,d", info.bought);
String time = info.resetInMs > 0 ? " - <col=ffff00>reset in " + formatDuration(info.resetInMs) + "</col>" : "";
return "<col=ff981f>Bought in 4h:</col> <col=ffffff>" + formattedBought + "</col>" + 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("<br>");
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("<br>");
}
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<AccountData> accounts, Runnable done)
{
clientThread.invokeLater(() ->
{
java.util.Set<Integer> ids = new java.util.TreeSet<>();
Map<String, ItemLedger> 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<Integer, String> 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<AccountData> accounts, Map<String, ItemLedger> ledgers, Map<Integer, String> 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<SourceValue> sources = new ArrayList<>();
final Map<Integer, Long> merged = new HashMap<>();
final Map<Integer, Integer> prices = new HashMap<>();
long total()
{
long sum = 0;
for (long v : values)
{
sum += v;
}
return sum;
}
void add(String label, WealthCategory category, Map<Integer, Long> items)
{
long total = 0;
for (Map.Entry<Integer, Long> 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<Integer, Long> 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<Integer, Long> 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;
}
}
}