3527 lines
98 KiB
Java
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;
|
|
}
|
|
}
|
|
}
|